The exhibition and its ID
“Ankommen? Migration, Glaube und Identität” (“Arriving? Migration, Faith and Identity”) was a multimedia exhibition on the migration history of Konstanz, told through faith, culture, persecution and new beginnings. It ran from 18 July to 23 October 2025 in the Turm zur Katz. Students of architecture, history, computer science, communication design and music design from the University of Konstanz, HTWG Konstanz and HfM Trossingen developed the concept in the winter semester of 2024/25. Around 80 of them built it the semester after.
The three floors of the tower are called Zuhause, Unterwegs and Ankunft: home, on the move, arrival. Visitors meet six life stories told by AI-voiced portraits, follow migration routes on interactive maps and visit religious sites in VR. What connects the floors is an ID. Every visitor gets a small paper booklet with a QR code on its cover. The first station, a registration office called the Meldebehörde, turns it into a personal document. From there on, the booklet switches on each station it is laid on and collects a stamp on every floor.
This page follows that ID, because almost everything I worked on is part of making it work: the station that issues it, the stations that read it, the record behind it, and the servers that kept all of this running for four months. The concept, the visual design and the illustrations are the work of the design students. Where code was shared, the names are in the text.
Issuing the ID
The Meldebehörde is an iPad on a counter, with a webcam above the display and a label printer next to it. A visitor lays the blank booklet on the markings, the iPad reads its code, and a form asks for a name, place of birth, place of residence, date of birth and a photo. All of it is optional. Whoever prefers gets an anonymous ID, made out to a specimen person called Toni Meyer. The station then prints a label with the photo and the details, and the visitor sticks it into the frame that the booklet reserves for it.
I implemented this station from the first screen to the printed label. How the screens look was designed in Figma by communication design students from HTWG Konstanz. The screenshots on this page are rendered from the exhibition’s code, with its specimen ID and sample data in place of the backend.
The form
Each question is a screen of its own, with its field in the upper half. The iPad’s on-screen keyboard covers the lower half, and nobody should have to scroll a form while standing at a counter. A place is entered as a country and then a town, and the town field suggests matches while the visitor types. The suggestions come from Google’s Places API, but the iPad never talks to Google. It asks the backend, which forwards the query and stores every answer in Postgres under the typed text, the country and the language. What one visitor has typed costs nothing for the next, and the API key stays on the server.


Two cameras
A station has two cameras with two owners. The iPad’s built-in camera belongs to the native app and watches the booklet. The picture for the ID has to come from the webcam on the counter, and the web page opens that one itself. iOS only tells a web page the names of the attached cameras after it has been allowed to use one. The station therefore requests camera access once, discards the result, and only then picks the webcam from the list by its name.
// iOS reveals camera labels only after a first grant
await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
const cameras = (await navigator.mediaDevices.enumerateDevices()).filter(
(it) => it.kind === 'videoinput' && it.label.toLowerCase().includes('logi')
);
const stream = await navigator.mediaDevices.getUserMedia({
video: { deviceId: cameras[0].deviceId }
});Daniel Fink’s app core answers the web view’s permission request itself, so no dialog appears in front of a visitor. After a countdown the current video frame is drawn onto a canvas and stored with the record as a PNG. The visitor may retake the picture once.
Printing without a dialog
A web page on an iPad cannot print without a dialog. The exhibition’s iPads run a native kiosk app whose core Daniel Fink wrote: a full-screen web view with a QR scanner and a print function bridged into it. The bridge works in both directions. The page calls the app through message handlers that the web view provides, and the app answers by dispatching events into the page.
// page to app
window.webkit.messageHandlers.setPrinterUrl.postMessage(`ipp://${ip}/ipp/print`);
window.webkit.messageHandlers.printHTML.postMessage(labelHtml);
// app to page
window.addEventListener('printStatus', (event) => {
this.zone.run(() => this.printEvents.next(event.detail));
});The label is a complete HTML document of 87 by 50 millimetres. The print formatter on the native side renders it without access to the web app’s files, so the typeface and the photo are embedded as Base64 before the document is handed over. The app sends it to the printer over AirPrint and reports the outcome as a printStatus event. I added that report to the app and wrote the web side of the bridge as Angular services. They pull each native event back into Angular’s zone and expose it as an observable.
A completed print job only means that the printer accepted it. Whether a label came out is something the station cannot see, so the last screen asks the visitor, and the answer goes into the event log.
Printing takes about a minute, and the screen uses that minute to explain how the ID works at the other stations.


Three label printers were set up, and a hidden screen on each iPad selects the one it prints to. Their status pages return a script of variable assignments that a browser cannot read from another origin. A small backend endpoint fetches and parses it for a staff page.

Reading it at every station
Every station is a stele with an iPad. Laying the closed booklet on the markings is the login: the kiosk app’s camera reads the code and passes it to the web app. Taking it away is the logout. If the code stays gone for five seconds, or a different booklet appears in its place, the station drops the session and returns to its start screen. Nobody walks up to a stranger’s half-finished answers.


From camera frame to session
The scanning is Daniel Fink’s part of the kiosk app. Every camera frame goes through Apple’s Vision framework. When a QR code appears or changes, the app dispatches a qrScanned event with its content, and half a second after the last frame that showed a code, a qrRemoved event. On the web side I wrote the service that receives these events. It accepts a code only if it is a version 4 UUID, bare or as the parameter of the booklet’s link, and ignores anything else a visitor holds under the camera.
The session rule sits on top of these two events in a shared service that Illia Todorov wrote. A removal starts a five-second timer. The same code coming back cancels it, a different code restarts it, and when it runs out the station’s reset handler is called. The delay keeps a nudged booklet from ending a session. I later reworked parts of the service and added its logging.
One app for all stations
The web app behind all stations is one Angular project in which eight students worked at the same time, each team on its own station. Every station has its own folder and route prefix, and its screens are swapped inside a named router outlet, so the frame around them, with background and language switch, stays in place. Of the layer the stations share, I wrote the bridge services and a base class for station screens. The common “place your ID” flow is joint work with Nader Halwani. The base class gives each screen hooks for showing and hiding and helpers for navigation. It also keeps track of every timeout and interval a screen starts and clears them when the screen is destroyed, so that an iPad running for months does not collect leftovers from thousands of sessions.
The same layer talks to the backend. An interceptor adds the access key to every request, and every call is retried five times at one-second intervals, which covers a short interruption. A switch in the environment replaces the backend with generated data, and the bridge services do nothing when the native app is missing. Every team could therefore develop its station in a desktop browser.
I also set up the rules the project was built under. A pre-commit hook runs ESLint and Prettier on the staged files, a second hook checks the commit message against the Conventional Commits format, and the GitLab pipeline lints and builds every merge request in all three configurations.
What the ID collects
At the end of each floor, a reflection station asks three questions about what the visitor has just seen: what home means to them, how often they have moved, where they would first look for connections in a new country. They answer by choosing one or several options, on a slider, or by dragging markers onto a map. Paul Nitzke implemented the map question, I implemented the rest, again from the design students’ screens. After the last answer the screen sends the visitor to the stamp pad, and the floor leaves its stamp in the ID.


Questions as data
The questions are rows in the database, not screens in the app. Each row has a floor, a position, a type and a translation key. The German and English wording lives in the frontend’s language files under that key, so the backend stores no text and one answer row serves both languages. An answer points to the visitor, the question and either one of the predefined options or a free value, such as the slider position or a place on the map. A check constraint makes sure that one of the two is present.
A message from the registration office
Once per visit the station interrupts. “You have received a message from the registration office”, it says. It addresses the visitor by the name on their ID, calls their activities in this room a cause for concern and orders a brief check. The check is a question from the official German citizenship test, drawn at random from a pool of about twenty with its answers shuffled. Afterwards the screen shows how other visitors answered, and the result, Bewilligt or Abgelehnt, goes into the ID as a stamp. The exhibition calls this a Störmeldung, an interference. It is one random question, and its result has no influence on anything else in the visit.


Whether the test appears is decided the moment the booklet is scanned. The station first asks the backend whether this visitor has already answered a test question. If not, it draws a random number against a threshold per floor.
private interferenceChance: Map<Floor, number> = new Map([
['eg', 0], // ground floor: never
['1og', 0.5], // middle floor: every second visitor
['2og', 1] // top floor: everyone who was spared
]);The draw is written to the event log. The comparison on the result screen is computed from all answers stored for that question.
It is the moment the details from the first station come back to their owner.
At the end of the visit, a station built by teammates reads all answers back and compares them with those of the other visitors.
The record behind it
The QR code on the booklet is an ordinary link to the exhibition’s website with a random identifier attached. Scanned with a phone it opens the website. Scanned by a station, only the identifier counts. There is no call that creates a visitor. The first time a station asks the backend for an identifier it does not know, the record is created and returned. From then on every station reads and extends the same one: the optional personal details, the language, the consent, the answers per floor. Holding the booklet is the only credential a visitor has.
The backend is a FastAPI application on Postgres that I built together with Nader Halwani. He wrote the speaking portraits, the pipeline that lets six characters answer in their own voice from historical documents. I wrote the visitor record and its API, the questions and answers, the place suggestions in the registration form, the event log that all stations and the kiosk app write to, and the key that every request has to carry. We set the project up with SQLAlchemy models, Alembic migrations and Docker Compose. FastAPI generates an interactive documentation page from the code. That page was the contract between the backend and the station teams.
Access
Every router depends on one check that compares a key in the request header with the one on the server. A browser cannot set headers on a WebSocket handshake, so the WebSocket routes read the same key from the query string. It is a shared key and not a login. Its job is to keep other devices on the network from writing to the database.
Stations in pairs
Two stations consist of a tablet and a projection. They are coupled through WebSocket channels on the backend, which Nader Halwani built: the tablet sends, the projector listens. My part is one trigger. When a visitor at the last station agrees to show their answers on the wall, the update to their record makes the backend tell the projection to reload.
Names and namesakes
One part serves a teammate’s station that shows where a visitor’s surname is common, together with well-known people who share it. The distribution comes from the Forebears name database. The people come from Wikidata: a SPARQL query asks for humans with that family name, and for up to thirty of them the backend fetches the Wikipedia summaries in parallel. That list is projected in a public room. Before it gets there, the backend removes everyone whose description contains words such as “war criminal” or “Nazi”, counts the living and the dead and keeps the first five. Both lookups are slow, so every result is stored and a name is only looked up once.
The event log
Every station and the kiosk app write to one log table: an event name, a timestamp and a JSON value that always includes the device. The frontend only accepts event names from a typed list. The log was the way to see from a distance what a station had been doing.
Keeping it running for four months
The exhibition was open for four months. The staff of the tower ran it day to day and called the computer science students when something did not work. Whatever broke therefore had to recover by itself or be fixable from a distance.
A trusted name on a private network
The server stood in the basement of the tower. The iPads reach it over the building’s own network, but the kiosk app only accepts a certificate that is publicly trusted. The server therefore holds a real wildcard certificate from Let’s Encrypt. Certbot obtains it through a DNS challenge at Cloudflare, so the machine never has to be reachable from the internet, and reloads nginx after every renewal. A Pi-hole container on the same machine is the DNS server for the stations and answers the public names with local addresses. nginx terminates TLS, serves the built frontend as static files and passes API calls on to the backend container. The disk is encrypted and unlocked with a hardware key.

Data
Postgres runs with logical replication switched on, and the backend sets the replication up by itself. On start it creates the tables on the main database and on a second machine, publishes all tables on the first and subscribes the second to it, including a copy of the rows that already exist. A replica that is added or rebuilt catches up without manual steps.
A script also writes a compressed dump every hour and keeps the last day in hourly steps, the last week in daily ones and one per week beyond that. Every run ends with a call to a heartbeat address, and a monitor raises an alarm when that call stays away.
Recovery
A script requests the backend’s root address at regular intervals and restarts the container when no answer arrives within ten seconds. I added the same idea to the kiosk app. It reloads the page when a navigation fails, when iOS terminates the web view’s content process, or when the page has loaded but its root element is still empty five seconds later. Each case is written to the event log. The web view was already set up to keep no website data between launches, so a restarted station always loads the current build.
Updates
Backend and frontend are updated by the same kind of script. It fetches the repository and compares the local commit with the remote one. Only if they differ does it pull, rebuild and restart, and it reports to the heartbeat monitor either way. A fix could go live without a trip to the tower.
Staff
The kiosk is locked to a single full-screen page, with two hidden ways out. They are a gesture recognizer of their own that counts the fingers on the screen and starts a timer. Seven fingers held for seven and a half seconds restart the station, eight fingers held for ten seconds quit the app. On start the app asks for the address to load, which is how one build of the app serves every station.
Not everything could be solved this way. Most calls from the tower were about the label printers, which kept failing for what looked like a firmware problem. We never really fixed that.
After the run
The exhibition closed on 23 October 2025. By then the registration office had issued more than 2,000 IDs. Names and photos were stored only for the duration of the exhibition. What was kept is a pseudonymised dataset of the answers, for research.
The exhibition has since received Gold at the ADC Talent Award 2026, Talent Gold at the DDC Award 2025, Silver at the European Design Awards 2026 and Bronze at the CommAwards 2025. The awards are for the exhibition as a whole, the work of around 80 people.

