Showcase#
Welcome to the cozy faraway Café! Hang out with your friends, enjoy your hot chocolate and watch films together! This is my first public VR world and this article documents the many challanges faced on the way
Creation#
Beginnings#
I initially copied my LabTopia world and then decided to place objects created in Blender into it.
I started with a long search for creating realistic looking landscapes in Blender. I tried out many plugins, but in the end I decided to stick with the built in Blender Landscape ANT to create the island. I then applied textures to the surface and even neatly painted them. Unfortunately, Blender does not export procedural textures, so I decided for a patchy solution and simply bake the textures. This meant that all the textures would be placed onto one, but all the individual textures together were mere 3 MB, the baked texture was 30MB and it was not looking HD.

I added rocks to the surface in a similar way to how Blender Guru taught us to distribute sprinkles on a doughnut: Using geometry nodes.

Then after that I modelled a building, setup the background and also imported the first animations.
and this got me wind turbines! I decided to also add sound and initially struggled a little bit, because the atp server does not support sound yet. This motivated me to move my models to Codeberg. Note at that point I was also using proprietary music, especially nighttime ambient music from Sonic Unleashed.

Next up I wanted to add the sea and some ponds. How do you approach the issue? With a Blender ocean modifier and then animations? Overte doesn’t support Vertex animations, so I ultimately decided to use tricks, such as rotating discs and animation scripts.
Early build and a new beginning#
I then couldn’t continue working on the world for a while because I was busy and I decided to restart the project for a few reasons, I was lacking clear planning and my project has already reached 100MB. When testing the world with some friends of mine, it took Overte a lot of time to load all the assets and to start playing music. It’s clear that something needs to change, so I decided to restart the project and this time follow a clear plan, clearly documented and optimised so that all assets are below 50MB. Here are the two screenshots of the old build:

Design principles#
[50MB max, only free and CC content, all should be cited. Following project folder structure:
KafejoFora/
├── assets/
│ ├── teksturoj/
│ ├── modeloj/
│ │ ├── blender/ # .blend files (backup)
│ │ └── eksporto/ # All the .glb and .fbx files
│ └── bildoj/ # Reference images, concept art
├── programaro/ # Animation javascripts, control panels
├── sono/ # Music and SFX sounds (CC-licensed)
├── mondo/ # Overte world files
│ └── mondo.json # Main world configuration
├── dokumentaro/ # Documentation, setup instructions (also in English)
└── LEGUMIN.md # Project overviewFolders, Variable names and comments in Esperanto, but the world itself should be by default in English, including all UI elements. Other translations should be available, especially German]
Core World Foundation#
Terrain Creation#
I’ve created the island, beach and the mountain using Blender ANT Landscape. Each got one texture assigned to it and I took the most lightweight textures from AmbientCG. To optimise for size I decreased the subdivisions of the landscape.

Next, the file had to be exported as glb, I also tried fbx, but had trouble with textures. I inserted it into the world to test its size and textures and I noticed that I needed to UV unwrap all the surfaces and scale the textures by 100 and finally in Overte the whole world had to be scaled by 100. By reducing the material image quality to 50% in the glb export options, we get to have a mondo.glb file that weighs only 4.7 MB.

Add Roads#
I spent almost an entire day trying to get the road to work. I followed this tutorial:
It was kind of fun laying down the road paths:

But unfortunately I had to redraw everything because the path was somehow broken. However, I then noticed by raising the roads in the z-axis by a little that the road was twisted in 3D at the curves:

This was very frustrating. However I found a solution! You could raise the road over the path and then modify the path so that the curves were less extreme. With a little bit of experimentation, I finally got the road the way I wanted! The problem was that the curves were too extreme.

I thus decided to redraw the road paths and start again. Since I didn’t want to modify the texture of the island by introducing new vertices, I decided to make the road 3D with concrete cobblestone on the sides as is often seen in serpentinous roads in Central Europe. What was very time consuming was to figure out the details of the tool, because the moment I decided to make the road 3D I had to tinker a lot with the settings and finally I still had to adapt the terrain a bit to the road by sculpting it.

Then I also had to add all the branching roads (for example the one linking the cafe with the main road), but there I had again issues with the tool, because I had to set the offset to Y=-1 for some reason. This took a lot of time and was a bit frustrating, but I’m finally done with roads! I hope that roads were the most challenging part of the process.

Water Surfaces#
Instead of using any kind of wave modifiers, I only created two square disks, one for the sea and the other one for the lake. I found an online source of free water normal maps. These normal maps will be used to create the impression that there are waves present. This was very quick and the files are also very lightweight, only roughly 200 kB.

For the sea I used the following animation script which makes sure that waves appear (y-axis movement) as well as appear moving (angular motion). Though because of stuttering I decided to comment out the waves (y-axis movement). The script is applied on the client side to save up server ressources. Since the sea caused animation artefacts, I’ve decided to use the animation scripts just for the baloon and the sailboat. The rotation can be used for the z-axis using a sine function to make the boat move more realistically.
(function() {
var self = this;
var tempo = 0;
this.preload = function(entityID) {
self.intervalID = Script.setInterval(function() {
tempo += 0.01;
var rotacio = {
x: 0,
y: tempo * 0.004,
z: 0,
w: 1
};
var pozicio = {
x: 0,
y: -34.15 + Math.sin(tempo) * 0.3,
z: 0
};
Entities.editEntity(entityID, {
rotation: rotacio,
position: pozicio
});
}, 100);
};
this.unload = function() {
Script.clearInterval(self.intervalID);
};
});This means it’s finally time to look for some models!
Problems on the way#
Unfortunately, when I exported the world again, I’ve noticed that something had gone wrong. Overte wouldn’t load any textures. But why? I decided to open the world file in Blender and it’s all purple:

I was at a loss as to why the materials did not work correctly, until I opened the UV editor and realised that me moving the Blender file prevented Blender from finding the textures.

Whew! I had to move the textures into the folder project folder and then reimport them. This isn’t going to happen again, from now on all project assets will be in the project folder.
Structural & Decorative Elements#
Scatter the world with sprinkles:#
I found awesome trees from Creazilla that together weigh less than 1 kB. I wanted to use geometry nodes and specifically followed the good old dognut tutorial to scatter the world with trees:
I created maps of probabilities where the trees can show up and tried to simulate real-world forests, pines for example growing at higher altitudes and in the north, while the south is populated by birch trees and the beaches by palm trees.

This lead to some very complex-looking geometry node configurations:

But unfortunately I decided against this approach for two reasons, firstly Blender was really struggling rendering the trees and second of all the geometry nodes didn’t work quite the way I wanted them to:

So I decided instead to export the trees as glbs, additionally optimise the texture size and then to insert them into the world in Overte. Should there be any performance issues, I would introduce zones.
Add Buildings#
- Wind turbine
I got the wind turbine from Creazilla. Note that since we’re using an animation, we have to make a FSX file. I simply took what I’ve worked on from the previous Kafejo Fora version.

- Environmental objects
I then added an Antenna Tower atop the mountain, added some houses, a hot air balloon and a sailboat:

I later replaced the antenna with a base transciever station that was much more lightweight (2,9 MB instead of 19).
- Trees
Remember how I created the forest with geometry nodes? Unfortunately that would slow the game down too much, so I opted to manually placing the trees in the world.
I’ve had a lot of problems exporting the tree models from Creazilla. Creazilla provides .obj files but Blender struggled converting them to .glb files. The problem: The textures were not exported, making the trees all white. I never found the issue, but managed to fix it by switching to the latest version of Blender (I’m mostly using 4.0 which is quite old by now).

After I finally found a way of exporting trees, I started saturating the world with trees to create forests. Since I decided against making the world a hiking world (because that would take a lot longer), I placed the trees only where the user could see them (assuming they would not leave the Café). For this purpose I also turned off the ability to fly.

Finally I also started placing regular and birch trees.

With that the forests have been successfully placed!
But before I got to celebrate and get to the final part of the design I got into the world where none of the objects would render. I asked the experts in the official Overte Matrix group for help and they told me that I should’ve made a single object for the forest. Well, it’s good to know that now. Eitherway Overte supports a useful feature: It is possible to see why some links might be broken. In my case I’ve had too many requests from Overte which were blocked by Codeberg:

Removing the forests did not fix the issue, indicating that its not the forest causing the problem, but much more the sheer number of download links accessed by Kafejo Fora. So this raises the question: Where can I store all my assets? I don’t want to overload Codeberg, but other options will also rate-limit downloads… Because of that I honestly tend towards using the asset server again. I’ve been told many times not to use because its buggy, but right now it is the best solution, hence I started uploading assets back to the asset server and replacing the model URLs with atp-addresses.

To replace the addresses of all the trees with their atp addresses, I actually downloaded the archive of the world and then used VSCodium to replace the old addresses with their atp-counterparts:

I rezipped the files and uploaded them. By the way, by inspecting the archive I also got to see how big the files were: All together they are 41 MB, so within our goal! Wuhu!
Before I move to the next chapter, there was a update in between, I actually changed the links to their counterparts on my Codeberg Pages branch and these work very well! Since Pages are way faster than the asset server, that’s what I’ll be sticking to :)
Ambient Sound:#
For the ambient sounds I decided to take the following two CC videos:
Wait but what about the café? I decided to play the music by default on the screen in the café: that way people can also always turn it off, if it bothers them. I’ll be placing the sea sound at the beach and as for the seagull sound, I will have to do some scripting to make it play every few minutes.
Focal Points#
Curate the Café#
The model for the café has been copied from GameAssetsFin’s Free3D model. I’m very thankful to him for publishing this low-poly model which looks amazing under CC Attribution. The only modifications I decided to undertake is to split the chairs from the restaurant and to partly open the windows.
Next I added the chair from before and then added a child instance to it (which is a text texture):

Then added the following two scripts (provided in the Overte repository) which creates a sit here button with all the functionality


The local script is: https://content.overte.org/Bazaar/Worlds/HQ_HiFi/content/Releases/usefulUtilities/sit/2019-10-03_11-59-00/sitClient.js
The server one is the same except with sitServer.js at the end. You have to make the text invisible by default, as the script loads a custom image. This is nice, because now we can clone the seats!
I added a whole load of new objects: lightbulbs, coffee, cheese (of course), a video screen (for cinema purposes), plants, a laptop, as well as some buildings, the antenna at the top

Add Control Panel#
Initially I wanted to add a control panel that would be controlled by buttons with custom scripts that would control the television screen, but then I decided to add a whole app (mainly to see how to make one) that appears when you enter the world.
I added the image of a rebote for the app in two versions: one black and the other one yellow in line with how apps work in Overte.
The app is composed of two scripts inspired by Alezia Kurdis from the Matrix group for Overte. The first script is the one that loads the app and is actually responsible for changing the website (it is attached as a server script to the screen).
I ultimately decided to only allow .mp4 videos as well as PeerTube and YouTube links. This is to ensure that no illegal content can be played in the television. I’m not opposed to people playing whatever they want, but they should host the files themselves.
TV-app in Javascript
```javascript
//
// televido.js
//
// Televida regilo por Overte (Kafejo Fora)
//
// Aldonu ĉi tiun skripton kiel klientan skripton al via WebView
// (Krei > Ecoj > Skripto). Ĝi aldonas butonon "REMOTE" al la tabulkomputilo,
// kiu malfermas la regilan interfacon de la televido.
//
// Permesataj filmetoj: PeerTube, YouTube, Jitsi kaj rektaj filmeto-dosieroj
// (mp4, webm, ogv, ogg, m4v). La hejma paĝo estas https://sepiasearch.org/.
//
// Bazita sur scene_app.js de AleziaKurdis: https://github.com/AleziaKurdis/inertia/blob/main/NEBULA-BAZAAR/scene_app.js
(function() {
//======================= KONFIGURACIO =======================//
const jsMainFileName = "televido.js";
const ROOT = Script.resolvePath('').split(jsMainFileName)[0];
const APP_NAME = "REMOTE"; // La nomo, kiu aperas en Overte
const APP_ICON_INACTIVE = ROOT + "bildoj/tv-malaktiva.svg";
const APP_ICON_ACTIVE = ROOT + "bildoj/tv-aktiva.svg";
const ICON_CAPTION_COLOR = "#ffae00";
const HOME_URL = "https://sepiasearch.org/";
const WEBVIEW_ENTITY_ID = ""; // Nebezonata, se la skripto estas sur la WebView-ento mem
const APP_URL = "https://turingon.codeberg.page/Overte-Models/KafejoFora/skriptoj/aplikajo/teleregilo.html";
const channel = "overte.tv.application"; // Kanalo por la komunikado kun la interfaco
//==========================================================//
let tablet = Tablet.getTablet("com.highfidelity.interface.tablet.system");
let appStatus = false;
let isRunning = false;
let button;
let thisEntityID;
let historyStack = [];
let historyIndex = -1;
// Liveri la identigilon de la WebView-ento, kiun la skripto regas:
function getWebviewID() {
return WEBVIEW_ENTITY_ID || thisEntityID;
}
// Forigi ĉirkaŭajn spacojn kaj kompletigi la protokolon "https://", se ĝi mankas:
function normalizeUrl(url) {
url = url.trim();
if (url === "") {
return "";
}
if (!/^https?:\/\//i.test(url)) {
url = "https://" + url;
}
return url;
}
// Kontroli ĉu la adreso estas permesata filmetofonto kaj redoni ĝin en ludebla formo; "" se rifuzita:
function validateUrl(rawUrl) {
let url = normalizeUrl(rawUrl);
if (url === "") {
return "";
}
let id = "";
let match;
// YouTube-videoj (watch?v=, youtu.be, shorts, live, embed) transformiĝas al enplektaĵo de youtube-nocookie:
if (/youtube\.com|youtu\.be|youtube-nocookie\.com/i.test(url)) {
match = url.match(/youtu\.be\/([A-Za-z0-9_-]{6,})/i);
if (match) {
id = match[1];
}
if (id === "") {
match = url.match(/[?&]v=([A-Za-z0-9_-]{6,})/);
if (match) {
id = match[1];
}
}
if (id === "") {
match = url.match(/\/(embed|shorts|live)\/([A-Za-z0-9_-]{6,})/i);
if (match) {
id = match[2];
}
}
if (id !== "") {
return "https://www.youtube.com/watch?v=" + id;
}
return url; // YouTube-paĝo sen video-identigilo (ekz. la hejmpaĝo) estas permesata senchange
}
// PeerTube-videoj (/w/, /videos/watch/, /videos/embed/, /watch/, /embed/) transformiĝas al /videos/embed/-formo:
match = url.match(/^https?:\/\/([^\/?#]+)\/(?:w|videos\/embed|videos\/watch|watch|embed)\/([A-Za-z0-9-]+)/i);
if (match) {
return "https://" + match[1] + "/videos/embed/" + match[2];
}
// Jitsi-kunvenoj (meet.jit.si kaj propraj serviloj kun "jitsi" en la adreso):
match = url.match(/^https?:\/\/([^\/?#]+)/i);
if (match) {
let host = match[1].toLowerCase();
if (host.indexOf("jitsi") !== -1 || host === "meet.jit.si") {
return url;
}
}
// La hejma serĉejo SepiaSearch (hejma kaj serĉopaĝoj):
if (/^https?:\/\/(www\.)?sepiasearch\.org(\/|$|\?)/i.test(url)) {
return url;
}
// Rektaj filmeto-dosieroj, kiujn la retumilo povas ludi:
if (/\.(mp4|webm|ogv|ogg|m4v)([?#]|$)/i.test(url)) {
return url;
}
return ""; // Nekonata aŭ nepermesata fonto
}
// Ŝargi la donitan adreson en la WebView-ento de la televido:
function setWebviewUrl(url) {
Entities.editEntity(getWebviewID(), { "sourceUrl": url });
}
// Sendi la nunan staton (adreso kaj eblecoj reen/sekven) al la interfaco:
function sendStateToUI() {
let state = {
"channel": channel,
"action": "CURRENT_STATE",
"data": {
"url": historyStack[historyIndex],
"canGoBack": historyIndex > 0,
"canGoForward": historyIndex < historyStack.length - 1
}
};
tablet.emitScriptEvent(JSON.stringify(state));
}
// Informi la interfacon, ke la enigita adreso ne estas permesata:
function sendWarningToUI() {
let state = {
"channel": channel,
"action": "WARNING",
"message": "Not allowed: only PeerTube, YouTube, Jitsi, or direct video files (mp4, webm, ogv, ogg, m4v)."
};
tablet.emitScriptEvent(JSON.stringify(state));
}
// Kontrolas la adreson, enmetas ĝin en la historion kaj ŝargas la televidon:
function navigate(rawUrl) {
let url = validateUrl(rawUrl);
if (url === "") {
// Nepermesata adreso: la televido restas ĉe la nuna video, la interfaco montras averton:
sendWarningToUI();
sendStateToUI();
return;
}
// Forigi la "sekvan" historion, kiam oni enigas novan adreson:
historyStack = historyStack.slice(0, historyIndex + 1);
historyStack.push(url);
historyIndex = historyStack.length - 1;
setWebviewUrl(url);
sendStateToUI();
}
// Iri unu paĝon malantaŭen en la historio:
function goBack() {
if (historyIndex > 0) {
historyIndex--;
setWebviewUrl(historyStack[historyIndex]);
sendStateToUI();
}
}
// Iri unu paĝon antaŭen en la historio:
function goForward() {
if (historyIndex < historyStack.length - 1) {
historyIndex++;
setWebviewUrl(historyStack[historyIndex]);
sendStateToUI();
}
}
// Reŝargi la nunan paĝon, saltrilante trans "about:blank" por devigi veran reŝargon:
function reload() {
let url = historyStack[historyIndex];
if (url === undefined) {
return;
}
Entities.editEntity(getWebviewID(), { "sourceUrl": "about:blank" });
Script.setTimeout(function() {
Entities.editEntity(getWebviewID(), { "sourceUrl": url });
}, 100);
}
// Iri al la hejma paĝo (SepiaSearch):
function goHome() {
navigate(HOME_URL);
}
// Prilabori la komandojn venantajn de la regila interfaco:
function onAppWebEventReceived(message) {
if (typeof message !== "string") {
return;
}
let instruction;
try {
instruction = JSON.parse(message);
} catch (e) {
return;
}
if (instruction.channel !== channel) {
return;
}
switch (instruction.action) {
case "UI_READY":
sendStateToUI();
break;
case "NAVIGATE":
navigate(instruction.url);
break;
case "BACK":
goBack();
break;
case "FORWARD":
goForward();
break;
case "RELOAD":
reload();
break;
case "HOME":
goHome();
break;
}
}
// Aperigi la tabulkomputilan butonon de la aplikaĵo:
function showButton() {
tablet.screenChanged.connect(onScreenChanged);
button = tablet.addButton({
"text": APP_NAME,
"icon": APP_ICON_INACTIVE,
"activeIcon": APP_ICON_ACTIVE,
"sortOrder": 0,
"captionColor": ICON_CAPTION_COLOR
});
button.clicked.connect(clicked);
isRunning = true;
}
// Malfermi aŭ fermi la regilan interfacon, kiam oni klakas la butonon:
function clicked() {
let colorCaption;
if (appStatus === true) {
tablet.webEventReceived.disconnect(onAppWebEventReceived);
tablet.gotoHomeScreen();
colorCaption = ICON_CAPTION_COLOR;
appStatus = false;
} else {
tablet.gotoWebScreen(APP_URL);
tablet.webEventReceived.connect(onAppWebEventReceived);
colorCaption = "#000000";
appStatus = true;
}
button.editProperties({
"isActive": appStatus,
"captionColor": colorCaption
});
}
// Observi la ŝanĝojn de la tabulkomputila ekrano por ĝustigi la butonon:
function onScreenChanged(type, url) {
let colorCaption;
if (type === "Web" && url.indexOf(APP_URL) !== -1) {
colorCaption = "#000000";
appStatus = true;
} else {
colorCaption = ICON_CAPTION_COLOR;
appStatus = false;
}
if (button) {
button.editProperties({
"isActive": appStatus,
"captionColor": colorCaption
});
}
}
// Purigi ĉion, kiam la uzanto ŝanĝas domajnon:
function onDomainChanged(domain) {
cleanup();
}
// Forigi la butonon kaj la konektojn:
function cleanup() {
Window.domainChanged.disconnect(onDomainChanged);
if (appStatus) {
tablet.gotoHomeScreen();
tablet.webEventReceived.disconnect(onAppWebEventReceived);
appStatus = false;
}
if (isRunning) {
tablet.screenChanged.disconnect(onScreenChanged);
if (button) {
button.clicked.disconnect(clicked);
tablet.removeButton(button);
button = null;
}
isRunning = false;
}
}
// Farita ĉe la ŝargo de la skripto: preni la nunan adreson kaj aperigi la butonon:
this.preload = function(entityID) {
thisEntityID = entityID;
let properties = Entities.getEntityProperties(getWebviewID(), ["sourceUrl"]);
// Komencigi la historion per la paĝo, kiu nun estas montrata:
let initialUrl = properties.sourceUrl;
if (initialUrl === undefined || initialUrl === "") {
initialUrl = HOME_URL;
}
historyStack = [initialUrl];
historyIndex = 0;
Window.domainChanged.connect(onDomainChanged);
showButton();
};
// Farita ĉe la fino de la skripto: purigi ĉion:
this.unload = function(entityID) {
cleanup();
};
Script.scriptEnding.connect(cleanup);
});
```
I also created a small UI with Mistral in HTML that is also similar to Alezia’s HTML. This one cannot be launched within Overte though. It must be hosted somewhere else, that’s why I’ve created a pages branch which creates a Codeberg page:
TV remote HTML
```html
<!DOCTYPE html>
<!--
teleregilo.html
Tabulkomputila interfaco de la televida regilo. Ŝargas ĝin la skripto televido.js.
Ĝi parolas kun la skripto per EventBridge:
- sendas: UI_READY, NAVIGATE, BACK, FORWARD, RELOAD, HOME
- ricevas: CURRENT_STATE, WARNING
Bazita sur scene.html de AleziaKurdis: https://github.com/AleziaKurdis/inertia/blob/main/NEBULA-BAZAAR/application/scene.html
Fono: simpla kafokoloro. Farita kiel kompakta 400 x 200 regilo.
Permesataj filmetoj: PeerTube, YouTube, Jitsi kaj rektaj filmeto-dosieroj (mp4, webm, ogv, ogg, m4v).
-->
<html>
<head>
<meta charset="UTF-8">
<title>Teleregilo</title>
<style>
@font-face {
font-family: "Ubuntu";
src: url(tiparo/Ubuntu-Regular.ttf);
font-weight: normal;
}
@font-face {
font-family: "Ubuntu";
src: url(tiparo/Ubuntu-Bold.ttf);
font-weight: bold;
}
html, body {
height: 100%;
margin: 0;
padding: 0;
}
body {
font-family: "Ubuntu", Arial, Helvetica, sans-serif;
background-color: #a0846c; /* kafokoloro */
overflow: hidden;
}
/* La karto */
#card {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 400px;
height: 200px;
box-sizing: border-box;
padding: 10px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.45);
display: flex;
flex-direction: column;
justify-content: space-between;
}
#notice {
color: #ffffff;
font-size: 12px;
line-height: 1.5;
text-align: center;
text-shadow: 0 1px 2px #000000;
}
#navbar {
display: flex;
gap: 4px;
}
#navbar button {
width: 36px;
height: 32px;
flex-shrink: 0;
border: 1px solid #777777;
border-radius: 4px;
background: #2b2b2b;
color: #ffffff;
font-size: 15px;
cursor: pointer;
}
#navbar button:hover {
background: #4a4a4a;
}
#navbar button:disabled {
opacity: 0.3;
cursor: default;
}
#address {
flex: 1;
min-width: 0;
height: 32px;
box-sizing: border-box;
padding: 0 6px;
border: 1px solid #777777;
border-radius: 4px;
background: #161616;
color: #ffffff;
font-size: 12px;
}
#address:focus {
outline: none;
border-color: #ffae00;
}
</style>
</head>
<body>
<div id="card">
<div id="notice">
<div><b>Television</b></div>
<div>Watch PeerTube, YouTube, Jitsi streams or videofiles in the
mp4, webm, ogv, ogg, m4v formats.</div>
</div>
<div id="navbar">
<button id="back" title="Go back">◀</button>
<button id="forward" title="Go forward">▶</button>
<button id="reload" title="Reload">⟳</button>
<input id="address" type="text" spellcheck="false" autocomplete="off"
placeholder="Enter address...">
<button id="home" title="Home">⌂</button>
</div>
</div>
<script>
var channel = "overte.tv.application";
var notice = document.getElementById("notice");
var address = document.getElementById("address");
var backBtn = document.getElementById("back");
var forwardBtn = document.getElementById("forward");
var reloadBtn = document.getElementById("reload");
var homeBtn = document.getElementById("home");
// Ĉu EventBridge ekzistas? Ene de Overte jes; en retumilo ne
var hasBridge = (typeof EventBridge !== "undefined");
// Sendi komandon al la skripto televido.js per EventBridge:
function send(action, extra) {
var message = { "channel": channel, "action": action };
var key;
for (key in extra) {
if (extra.hasOwnProperty(key)) {
message[key] = extra[key];
}
}
if (hasBridge) {
EventBridge.emitWebEvent(JSON.stringify(message));
}
}
// Montri avertmesaĝon en la noto-zono dum kvar sekundoj:
var defaultNotice = notice.innerHTML;
var noticeTimer = null;
function showNotice(message) {
notice.textContent = message;
if (noticeTimer !== null) {
clearTimeout(noticeTimer);
}
noticeTimer = setTimeout(function() {
notice.innerHTML = defaultNotice;
noticeTimer = null;
}, 4000);
}
// Aŭskulti uzanto-enigojn
backBtn.addEventListener("click", function() { send("BACK", {}); });
forwardBtn.addEventListener("click", function() { send("FORWARD", {}); });
reloadBtn.addEventListener("click", function() { send("RELOAD", {}); });
homeBtn.addEventListener("click", function() { send("HOME", {}); });
address.addEventListener("focus", function() { address.select(); });
address.addEventListener("keydown", function(event) {
if (event.key === "Enter") {
send("NAVIGATE", { "url": address.value });
address.blur();
}
});
// Prilabori la staton kaj la avertojn de la skripto
if (hasBridge) {
EventBridge.scriptEventReceived.connect(function(message) {
var state;
try {
state = JSON.parse(message);
} catch (e) {
return;
}
if (state.channel !== channel) {
return;
}
if (state.action === "CURRENT_STATE") {
address.value = state.data.url;
backBtn.disabled = !state.data.canGoBack;
forwardBtn.disabled = !state.data.canGoForward;
} else if (state.action === "WARNING") {
showNotice(state.message);
}
});
// Demandi la skripton pri la nuna stato
send("UI_READY", {});
}
</script>
</body>
</html>
```

Finale#
For the grand finale I decided too add a few easter eggs such as the animated seagull from the default offline homeworld, which you can add with the following link even offline: qrc:///serverless/Models/seagull-ANI.fbx (don’t forget to add the same link to the animation).

Next up I had to set the spawnpoint in the cafe itself, since (0,0,0) is located inside the mountain. This can be changed in the dashboard menu by typing in the location where you want the player to spawn (you can find that location for example with the Dom Nav app).

Publishing#
I created the logo for the café with Mistral. I drew a rough sketch of the logo, uploaded it so Mistral would create a prettier version, modified it slightly in Krita and then vectorised it with an online poll, which neatly packaged it as a svg allowing me to create the thumbnail (which is the same one as for this article)
- Server Deployment
This is the final step that is left, but I’m still waiting for the right moment to purchase a server at Netcup, so for now I’ll be hosting the world with my Raspberry Pi.
Reflection#
Some aspects of Overte are still a bit rough, but Raspberry Pi and FOSS are just priceless. It took me a lot of time to make the world, not just because of university but because I had to learn a lot of things and look for workarounds when things did not go the way they should.
I will return to Overte world creation, because I enjoy making worlds. It can be very frustrating when looking for solutions, but while I am not drawn into Blender modelling, I find interior design uniquely satisfying. Already in the Club Penguin days I loved taking care of my igloo and placing lots of potted plants in them. I am someone who loves creating worlds, systems, devices etc and am interestingly not that drawn to the individual processes themselves (such as modelling), they are just a means of achieving the whole. It’s super strange, but it’s the way I enjoy things. While I won’t buy a VR headset, I do enjoy VR, because it lets me explore and build worlds as I please.
Reply by Email