Skip to content

Repository files navigation

gamescom 2026 Guide

An unofficial, fan-made web guide to gamescom 2026 (Cologne, Aug 26–30, 2026).

The show is over. gamescom 2026 closed on Sunday, Aug 30. The site stays up as it stood on the last day and says so at the top of every view — see After the show below. There will be a guide for gamescom 2027 (Aug 23–29); it gets announced on @inventivtalent and u/InventivetalentDev.

Features

  • Overview of exhibitors with their announced (or rumored) games and products
  • Hall & booth locations where known — clearly marked when unconfirmed
  • A link to the exhibitor's official gamescom page where one exists, for the booth program straight from the source
  • Sources on every entry — an ⓘ marker in each card's foot opens the pages that booth number, lineup and queue call were built from, with the date it was last checked, so an unconfirmed detail can be checked before you plan a day around it
  • Search across exhibitors, games and tags; filters by category, hall, playable demos, games featured at Opening Night Live and age gate (hide 18+ / 18+ only)
  • Saved list — bookmark booths and individual games, then filter both the exhibitor grid and the queue-priority list down to just those
  • Shareable saved lists — move a plan to another device or send it to a friend with a link or scannable QR code
  • Share the guide itself — a Share button in the masthead opens a QR code big enough to hold up to the phone of whoever you are queueing with, plus the link to copy for posting it anywhere else
  • Share one booth — every card's corner carries a Share row beside Save and Played. It hands out a link that opens the guide on that card, scrolled to it and lit for a moment, rather than at the top of a list of a hundred and eleven — so "Capcom is in 9.1" can be a link instead of a description. A card turned to its business booth shares the business booth, and a link naming one switches the trade exhibitors on to answer it
  • Crowd forecasts (1–5) per exhibitor and a Visit planner with queue-priority list, 18+ wristband checklist and day-by-day advice
  • Live queue times during the show — a Live queues tab, open only while the show is on, that lists the lines you are standing in and searches all ~160 queues to report a new one; the cards themselves just show the figures, with one Report a queue link that opens the tab already narrowed to that booth. Optional reports under a random, resettable device id per playable game (or per booth where there is no playable lineup), server-side aggregation with report count and age, measured waits that can finish offline, and a phone-first moderation console. How a line moves — one by one, in pairs, in groups or waves of ~n — can be said from the row of any line you are in, not only in the dialog that starts the timer: a wave queue usually reveals itself twenty minutes in, long after that dialog was closed. A queue nobody has reported yet today falls back to yesterday's figure — the hourly cleanup condenses each day's reports into anonymous per-queue statistics (counts and median waits, no device ids) before the 24-hour privacy sweep deletes the raw rows, and those aggregates both feed the "Yesterday: ~n min" estimate and keep show-long metrics
  • Your plan — one board for everything you saved, arranged by day (assign each stop a day, see that day's hours inline, export to calendar) or by hall (walking order, with the same day chips on every stop and a single-day filter); the five-days board counts each day's planned stops and taps through to them. Each stop wears its queue index in both arrangements, and ▲▼ put the list in the order you'll actually walk it — the guide opens with busiest-queue-first and hands the order over the moment you disagree; the hall map numbers its pins from the same order. Either arrangement reaches the map from its own headings — a hall heading opens that hall, a day heading opens the whole site with that day lit
  • Hall map — every hall drawn booth by booth, with exhibitor names on the booths and your saved ones lit up. Tap a booth for its lineup, its queue call and — once you've assigned it in the planner — the day you planned it for. Entertainment halls and trade-only business halls are each washed in the colour the official plan gives that area, and the business ones are flagged as the door a consumer ticket does not open. Pick a day and that day's stops are pinned on the floor in your plan's own order — the one you arranged, or busiest first with played ones last until you do — so "Thursday, hall 7.1" is a picture rather than a list. It does not stop at the wall: the halls either side of this one in the plan are named in the bar, a tap from opening, and where a doorway is known to start the way there the route runs out to it and an arrow points through. The overview answers the same question for the whole site — every hall that day touches, lit and numbered in order. Every hall or booth number named anywhere in the guide — card plates, your plan, queue priority, the full directory, the halls and areas in Event info — opens the map on that stand. It works offline like everything else
  • The booths the guide never carded, saveable anyway. The guide writes 170 cards; Koelnmesse's directory files 1,785 exhibitors, and 823 of them stand only in the entertainment halls — mostly indie studios sharing one of hall 10's collective stands. Tap such a stand on the map and the sheet lists whoever is filed on it, each saveable as a stop of your own: hall 10.2's Indie Arena Booth is one footprint with 171 studios on it, so the list has a filter box and puts the ones you have marked at the top. Every row of the full directory carries the same save button, which is the other way you find one name among 1,785. A booth saved this way is a stop like any other — it takes a day, lights its stand, gets numbered on the route and exports to your calendar — and it never pretends to be more than it is: no queue call, no lineup, no crowd forecast, and the map still tells you at a glance which booths the guide actually covers and which it does not
  • Trade exhibitors, behind one setting — "I have a trade badge", off by default. Switch it from the Badge row at the top of the filters, from Event info, from the trade section itself, or from the map's business-area banner. It opens the business halls (2–4): ~820 booths the guide otherwise walks past, saveable and plannable like any other stop, with product-group filters and curated cards for the platforms, services and national pavilions standing there. Each booth says whether it is an open stand or a closed room you need an appointment for — the one thing that decides whether walking over is worth it. A booth saved there is a stop in the same plan as your Thursday demo queue, and the planner warns when one lands on a day the business area is shut
  • Two-faced cards for the twenty-odd exhibitors with a booth on each side of the show. Capcom demos in Hall 9.1 and takes meetings in 4.2; tap the small purple square in the corner of the hall plate and the card turns over to its business booth, tap the cyan one to turn it back. The square is the other booth's plate in miniature, in Koelnmesse's own colours — purple for the business halls, cyan for the entertainment ones — so a plate means the same thing here as on the official plan. The two booths stay separate stops in your plan, because they keep different hours
  • Event info: dates, hours, tickets, special areas with the way onto the map for the hall each stands in (and, from the heading above them, onto the whole site at once), Opening Night Live, and which of the four entrances to use — including what a trade badge changes about that and about when you get in
  • Installable and offline-capable — add it to your home screen and the whole guide stays readable in a hall with no reception

Install / offline

The guide is a PWA. Chrome, Edge and Samsung Internet offer an Install app button in the masthead; iOS Safari gets the same button pointing at Share → Add to Home Screen. Installed or not, sw.js caches the shell and the data on first visit. That is the point of the whole thing: mobile reception inside Koelnmesse's halls is poor, and the guide is most needed exactly where the network is worst.

Freshness is not traded away for it. Exhibitor data is served network-first and only falls back to the last good copy when the network fails — the masthead says Offline · showing saved data when that happens. A new deploy offers a Reload prompt rather than swapping the page out from under you.

Serve it over HTTPS (or localhost); service workers are inert on file:// and on plain HTTP.

Running locally

For static UI and data work, serve the repo root with any static server:

python3 -m http.server 8000
# then open http://localhost:8000

(Opening index.html via file:// won't work because the app fetches JSON.)

One part of the guide can't be reached that way: the Today tab exists only on the five show days, so before Aug 26 there is nothing to click. ?now moves the clock for one load, on whatever server you already have:

URL
?now the show's first day, an hour after the doors open
?now=14:30 same day, that time in Cologne
?now=2026-08-29 that day, an hour after its doors
?now=2026-08-29T19:45 that day, 15 minutes before closing

It works anywhere the page does — a file://-adjacent IDE server, a phone on the LAN, the deployed site — and combines with the rest: ?now=2026-08-29#today, ?now&lang=de. Today only has stops if you gave some a day, so save a couple of booths in the planner first, or you'll get its empty state.

A moved clock says so: a bar across the top of every page names the moment it is pretending to be, and links back out. It has to, because otherwise a screenshot of ?now=2026-08-27 is indistinguishable from the real Thursday. Like ?lang, it wins for one load, is never stored, and never rides in a link the guide builds.

Running the queue API locally

A plain static server has no live queue API. The API is a second Worker, and in production a route puts it on the same hostname as the site; locally a small proxy stands in for that route. Node 22+, then two terminals:

npm install
cp .dev.vars.example .dev.vars
npm run db:migrate:local

npm run dev:api     # terminal 1 — the queue Worker on :8787
npm run dev         # terminal 2 — site + /api proxy on :8000
# open http://localhost:8000/?queue-dev=1

npm run dev serves the repository's real files, so an edit shows on reload with no build step in between. The example's paired clock values put the Worker inside the show window so the time-gated report flow can be exercised before Aug 26. They live only in the ignored local environment file, never in Wrangler's deployed configuration or a request field.

Deploying

Live on Cloudflare Workers as two Workers sharing every hostname: the site is static files served straight from the edge with no script at all, and a second Worker answers /api/* for live queues, backed by D1. A route beats a custom domain for the paths it covers, which is what puts them on one origin. .github/workflows/cloudflare.yml verifies pull requests and deploys main; wrangler.toml configures the site and wrangler-api.toml the API. Keeping them apart is what stops a pull request preview — which inherits its Worker's bindings — from ever running against the show's real database. There is still no client compile step: tools/build-site.sh copies the site into dist/ for upload, while worker/ remains server code outside the asset directory. Pull requests from branches in this repo each get a preview, deployed nowhere; forks deliberately do not (docs/DEPLOYING.md, PR previews).

It runs there rather than on GitHub Pages because Pages gives a repository one custom domain and the guide answers on four: hallgui.de, plus gamescom.guide, gc26.guide and the original gc2026.inventivetalent.org for as long as those three take to drain. All three are bookmarked or installed on somebody's phone, and a saved list is per-origin — so they keep serving the guide rather than a redirect until the people standing on them have been offered the move.

Arriving on one of them for the first time is the exception. Nothing in localStorage, no installed app and a working connection together mean there is nothing on that hostname to strand, so the head of each page sends that visit straight to hallgui.de — path, query and hash intact — before anything paints. Everyone else stays and is offered the move instead.

The address moved twice, for different reasons. gamescom.guide had to go: the name is a registered mark of game — Verband der deutschen Games-Branche e.V., who license it to exhibitors and have had unofficial sites warned off carrying it in a domain. gc26.guide replaced it and then went too, for a softer reason — it is an abbreviation with a year in it, fine to type and useless to say across a queue, and it would have needed replacing again next August.

hallgui.de is the one that stays. It names the thing the guide is actually built around, the hall map; it reads as "hallguide" once the dot stops being punctuation; and it has no year in it. Six alias domains redirect to it rather than serving the guide, so they cost nothing in extra origins — hallguide.de most importantly, because it is how somebody who only heard the name would spell it, and owning both means it does not matter which side of the de they put the dot on.

Throughout all of it the guide still calls the show by its name. That is ordinary referential use and was never the part anyone objects to; only the domain moved.

Any static host works, but two routing options are not optional if you move it elsewhere: /map.html must be served without a redirect, and / must serve index.html. See docs/DEPLOYING.md for the setup, the domain cutover and why those two matter to the service worker.

Design

"Hall Signage" — trade-fair wayfinding crossed with print. The hall number is what you actually read while walking a show floor, so it gets a solid plate and the largest type on the card, filled in that hall's own colour on the official plan — cyan for the entertainment halls, purple for the business ones, the same fills the map washes those halls with (dashed edge when the location is still a guess, grey plate when it's TBA). Exhibitors are discrete panels with hard, unblurred offset shadows rather than cells sharing hairlines. One signal colour, near-square corners, no gradients, no emoji, no blurred elevation.

Two details that look like mistakes but aren't:

  • The card offset shadow is lighter than the ground, not darker. A black shadow is invisible on a near-black page; offsetting in a lighter tone (--plate-shadow) gives the same "printed second layer" read as ink on paper, inverted.
  • Anton is used for structural headings only — page and section titles, weekday labels, the wordmark — and for exactly one element inside a card, the tilted N PLAYABLE stamp. Exhibitor names stay on Archivo Narrow, and the per-game badges stay quiet outlines: repeating either treatment down a 23-game lineup turns emphasis into noise.

Everything is driven by the tokens at the top of css/style.css. Two earlier directions — a light "Fanzine" and an all-monospace "Console" — were built on the same tokens and dropped when this one was picked; css/themes.css is recoverable from git history if you ever want to compare again.

Typefaces are self-hosted in fonts/ (Archivo, Archivo Narrow, JetBrains Mono, Anton — all OFL). Embedding Google Fonts by URL sends every visitor's IP to Google, which German courts have treated as a GDPR violation, and this guide's audience is overwhelmingly German. To refresh them, re-run the download step in docs/UPDATING.md.

The saved list

The + in a card's corner saves a booth; the + on a lineup row saves a single game. Both are kept in localStorage under gc2026.saved.v1 — no account, no server, and nothing leaves the device unless you share a link yourself. Two tabs of the guide stay in sync via the storage event, and if storage is blocked altogether (Safari private mode) the list still works for the session instead of throwing.

The corner itself is three rows — Save, Played, Share — one per thing you can do with a booth, each carrying its own word rather than a bare glyph, and each the full width of a fixed column, so pressing one never resizes the button under the thumb that pressed it or shoves the exhibitor's name sideways.

Shared links encode guide identifiers only — compact fixed-width hashes of exhibitor ids and game titles, never the names themselves. The link and its QR code are built entirely in your browser, so no saved-list data is uploaded or sent anywhere by the guide. The dialog asks who the link is for. Someone else carries the saved list alone by default — a plan you hand to a friend, and your progress through it is not theirs. Another of my devices brings the day plan and played marks along too, and on arrival offers Replace my list instead of adding: replacing is what lets a removed booth or a rescheduled day carry across your own devices, and the prompt counts what it would remove before anything is touched. The include boxes stay adjustable in either mode, and nothing about the choice is remembered between opens.

Sharing the guide rather than a list is a separate control — Share in the masthead — and carries nothing of yours: it is the canonical address, once as a QR code sized to be scanned across a queue and once as text to copy. Where the OS provides a share sheet it is offered too; there are no per-platform buttons, because a copied link reaches everywhere one of those would have.

Sharing one booth is the third of the three, and the smallest: the Share row in a card's corner hands out #exhibitors?ex=<id>, which carries nothing of yours either. It is the address the hall map has always used to send someone to a card, so what arrives is the guide scrolled to that booth and the card lit for a moment — which is the whole difference between a link and a description. A card turned to its business booth shares the business booth rather than its owner, and a link naming a booth in halls 2–4 switches the trade exhibitors on to answer it rather than landing on an empty grid; a toast says so, and the Badge row switches them straight back off. Same sheet as the other two — the link first, because this one is usually sent rather than held up, with the QR under it for the times you are standing together after all.

A link for someone else never replaces an existing list: it asks before adding to a non-empty list, while an empty list imports immediately with an Undo option — and a replace is undone the same way, day plan and played marks included. Opening a link consumes it — it does not stay in the address bar to be forwarded on — so until the question is answered the tab keeps its own copy: dismissing the prompt costs a reload rather than the whole list. Answer it, or close the tab, and the copy is gone.

The Visit Planner turns those bookmarks into a plan — one board, two arrangements. The day view is the itinerary: assign each stop a day, see that day's hours and crowd advice inline, export the assignments to a calendar file. The hall view is the walking route, and it writes those assignments as well as reading them: every stop wears the same row of day chips the day view gives it, so a stop can be placed — or moved, booth and saved games together — without leaving the walking order. A stop split across two days lights both chips part-way rather than reading as unplaced, and pulling it back onto one day is the one move here that forgets something, so the toast hands it straight back. Once anything is placed, a filter row above the board narrows the route to a single day's stops in hall order. Filtered to a day, the hall view's map links hand that day to the hall map, which numbers the same stops on the floor in the same order — the numbers and the rows come from one comparator, so they can never disagree. Assignments are kept separately under gc2026.itinerary.v1, follow the same booth and game keys, and stay on this device too; the arrangement you last picked is a view preference in gc2026.prefs.v1.

Within a day, or within a hall, the order is the queue index descending with played stops last — until you say otherwise. The ▲▼ on each row move a stop up or down its group, and from the first move the plan keeps the order you left it in, under gc2026.planorder.v1: a flat list of stop keys that both arrangements and the hall map's numbered pins read. Nothing is stored until you move something, so a plan you never argued with sorts itself, and Reset order — with an Undo in the toast — hands it back to the queue index. New stops join at the end of their group rather than shouldering into the middle of a plan you have already thought about.

It is entirely local, so it needs no network: with the app running off the service worker cache in a dead-reception hall, the list still renders, both saved-only filters still work, and anything saved while offline persists. That is the case it is for — you build the list at home and read it on the show floor.

Which is also why the filtered list is a route, #saved, and not only a checkbox: the installed app carries a Saved launcher shortcut straight to it, so your list is one long-press from the home screen instead of three taps into a filter drawer. It is listed first of the four shortcuts because launchers truncate — if anything gets cut it should be Updates, not this. On the exhibitor list the URL owns the filter: #saved turns it on, #exhibitors clears it, and the tabs route through whichever you had set, so switching to the planner and back keeps your filter. A bare #saved route survives a reload; the Share list control builds a separate, self-contained link for moving the actual items.

Games are keyed by title, not by booth. Eight titles this year are shown at two booths at once — Alien: Isolation 2 sits at both Xbox and SEGA — and someone who saved the game wants to see every booth running it so they can walk to the shorter queue. The same rule drives both saved-only filters: an exhibitor counts as saved if you saved the booth itself or any game they are showing, so saving Fable is enough to keep Xbox in your filtered queue-priority list. That list also names which of your games each booth is running, and keeps its ranks absolute — 04 still means "fourth worst queue of the show", not "fourth row you happen to be looking at".

One consequence for data edits: renaming a game in data/exhibitors.json orphans that game in anyone's saved list. See the editorial rules in docs/UPDATING.md.

Played tracking

The beside every game, and the Played row in a card's corner, record what you have already played. A booth counts as played when you tick it directly, or when every game you saved there has been played. Save another game at a booth that was complete that way and the booth becomes active again until you play the new addition.

Played rows use a muted filled plate and dim in place rather than taking the orange signal colour: saved things need attention, while played things should recede. In the queue-priority list, played booths sink below the unplayed ones without changing their absolute ranks, and Hide played removes them altogether. Itinerary day groups get the same treatment: played stops dim and sink to the bottom of their day, so each group leads with what's still left to do.

Played marks live in a second local-only storage entry, gc2026.played.v1, separate from the saved list; Hide played is a view preference rather than a mark, so it sits in gc2026.prefs.v1 next to the age filter. All of it works offline, stays in sync between tabs, and never leaves the device.

Played marks are deliberately not part of a shared link by default. A shared list is a plan you hand to someone else, and your progress through it is not theirs. Moving between your own devices is what the Played marks box in the share dialog exists for — there your progress follows you, alongside the day plan.

Moving yourself between the guide's hostnames is the one case that is not a share, so it carries everything: accepting the notice on any of the three draining hostnames brings the saved list, the played marks and the day assignments over to hallgui.de in one step. Somebody who accepted an earlier notice gets asked once more, because what they accepted then was an address that has since moved on. See docs/DEPLOYING.md.

Today

On the five show days a fifth tab appears at the front of the row, and the guide stops being a pre-show tool. Today is the walking route scoped to the day it actually is: your stops for today in hall order, the ones you have ticked folded away at the bottom, and a header that says whether the halls are open, what time they close and how long that leaves. When more than one stop is left it names the one with the worst queue, because that is the decision the morning actually turns on. Stops with a trusted live wait also carry the last moment you can join their line and still be let in, and that moment is only stated once it is one to act on: within the next hour and a half, past the halfway point of the day's own hours, or already gone, in which case the row says so. A column of times that are all hours away buries the one that is not.

It is a lens, not a fourth list. Today reads the saved list, the played marks, the day assignments and the order you put the plan in, and stores nothing of its own — reorder a hall in the planner and Today walks it that way. That has one consequence worth stating: a stop with no day can never appear on it. The count under the list says how many are in that position and leads to the planner, where they get one. Opening the guide mid-show lands on Today whenever the day has stops on it — an empty Today would be a worse front door than the exhibitor grid, so it only leads when it has something to lead with.

Which day it is gets read in the show's own timezone rather than the device's, so a phone still set to another continent does not open Wednesday's plan on Thursday morning. The hours come from open/close in data/event.json, the same two fields the calendar export writes, so a schedule change stays a data edit. The clock is refreshed whenever the tab is opened or the app comes back to the foreground — a phone that spent an hour in a pocket must not come back saying "closes in 3h".

The one thing it does not do is arrange. The reorder arrows stay on the planner, which shows the whole plan; Today shows one day with the played stops folded away, and arrows there would rearrange a list against stops you cannot see.

Off-show none of it exists: the tab is gone, and #today lands on the planner rather than on a page explaining that gamescom is not running. That is also why there is no launcher shortcut for it — the manifest is a fixed, year-round artifact, and launchers truncate the list it already has.

Feedback

Once the show is nearly over the guide asks the only question it has: was this worth doing, and should it come back for 2027. A card above every view, and a link in the footer, both pointing at a short hosted form. docs/FEEDBACK-FORM.md holds the questions in both languages and the setup.

It is a hosted form rather than an endpoint in worker/, for a reason that is about timing rather than taste. That API's D1 database is deleted after the show — docs/DEPLOYING.md removes the bindings and drops both databases once the last deferred queue outcome has landed, because the privacy promise is deletion — while feedback arrives on the opposite schedule: a little on the last day, most of it in the week after. A table there would be torn down in the middle of its own collection window. Free text would also be the first thing the guide ever stored that the 24-hour sweep and the anonymous hourly aggregates were not designed around.

The host is Tally — Belgian, EU servers — and not Google Forms, which would have undone on the last page a visitor sees what the fonts and the missing analytics are for everywhere else. privacy.html names Tally BV in both languages as the processor. The one thing the link carries is a ?source= word baked into it by hand, so "the card works, the Reddit post doesn't" is a fact rather than a guess; no script runs and nothing is read off the device to build it.

Everything about it is off until data/event.json names a URL, so it ships dark and goes live with a data push rather than a deploy:

"feedback": { "url": "", "urlDe": "", "from": "2026-08-29" }

An empty url hides every surface. urlDe is optional and falls back to url. from is an ISO date read in Cologne's timezone — the card stays hidden before it, and there is no end date, because after the show is when the answers come in. The card asks once: answering it or dismissing it writes gc2026.feedback.v1 and it never returns on that device. The footer link has no memory, which is what someone coming back a week later needs. Corrections keep their own footer line and their own mail address — "hall 9.1 is wrong" wants a reply and a data push, not a survey row.

After the show

The last doors shut and the guide stops being advice. Everything in it — sold-out ticket classes, which gate to use, be there for the 09:00 doors — was true on the day it was written and is now read on a page somebody may open in November, so the guide says at the top of every view that it is finished:

The show is over. gamescom 2026 closed on Sunday, Aug 30. Nothing here has changed since, and nothing will — this is the guide as it stood on the last day. Your saved list, your plan and the hall map still work; live queues closed with the show.

None of that prose is rewritten into the past tense. A record edited after the fact is a worse record than one that is honestly dated, and the notice is the date stamp. data/i18n/*.json's meta.note makes the same claim in the footer, next to lastUpdated.

The card appears when showIsOver() in js/app.js finds the last entry in days behind us in Cologne — the same clock the Today tab and the countdown read, so a phone still set to Los Angeles cannot see it an hour early, and Sunday afternoon still reads as a show day. It survives ?now=, which is how to look at either side of it. It has no dismiss button: this is the state of the whole guide, not a request, and a dismissed one would leave the site reading as though the doors were open.

The other half is the only thing left to act on. There will be a guide for gamescom 2027 (Aug 23–29, announced on the floor at gamescom 2026 — sources are in data/event.json), and this is where it will be said:

"next": { "name": "gamescom 2027" },
"follow": [
  { "network": "X", "handle": "@inventivtalent", "url": "https://x.com/inventivtalent" },
  { "network": "Reddit", "handle": "u/InventivetalentDev", "url": "https://www.reddit.com/user/InventivetalentDev/" }
]

No mailing list, and that is a decision rather than an omission. Nothing here has ever asked a visitor for an address; the queue API's own database is deleted after the show precisely so that nothing is left holding anyone's data (docs/DEPLOYING.md), and the last screen of the year is not the place to start collecting some. Two accounts that already exist cost nothing to keep and nothing to leave. The card does not argue any of that — it reads Follow along here and points at them. Naming the absent thing put a reader in front of what the guide does not do before showing what it does. url is validated as http(s) the same way the feedback form's is — it is the other value in a data file that becomes a navigation — and an entry without a usable one is dropped rather than rendered as a dead link.

Both fields ship dark: no follow, no links, on the card or in the footer. next without a name drops only the line about next year. The date range is prose and lives beside event.dates in data/i18n/*.json as nextDates, written without a year because name already carries one.

Two surfaces, the same split the feedback prompt uses. The card exists only once the show is over; the footer line is there year-round, because where do updates get posted is not a question that waits for Sunday night.

Architecture

Editorial content lives in data/ as JSON; the app (index.html, js/app.js, js/queue.js, css/) is a thin renderer. Ordinary guide updates still require only JSON edits. worker/ owns the live queue API, D1 migration, estimator and moderation page; js/pwa.js and sw.js keep offline guide content separate from every /api/ response.

The hall map is a second page (map.html, js/map.js, css/map.css) rather than a fifth tab: it wants the whole viewport, and a full-screen pan/pinch surface fights the app's own scrolling. js/marks.js holds the few rules both pages must agree on — the saved/played storage shape, the booth-code normalisation that decides which stand lights up, the dir:<slug> identity a business-hall booth is saved under on either page, and the plan's two rules: which day a stop is on, and which stop is number 1. Anything either page kept its own copy of would drift, and a map that numbered your Thursday differently from the list it read it off would be worse than no numbers at all.

Every list in the app is drawn from a function that returns one row's markup as a string — card(), tradeRow(), directoryRow() — and renderKeyed() in js/app.js is what puts those strings on screen. It keeps the element it built for each row alongside the markup it built it from: a row whose markup has not changed is handed back as the same element rather than parsed again, and the list is then walked into the order asked for with the fewest moves. That is what makes re-sorting the grid cost about what moving 111 cards costs rather than what building them costs — the measurements are in the comment above the function. Two rules fall out of it:

  • The markup is the cache key. Anything that should change a row has to change what its row function returns. Everything those functions read — the marks, the age filter, the expanded set, the language — is already in what they return, so this holds by construction; a row that reached outside for state would go stale invisibly.
  • Per-row listeners are delegated — on document, or on the list's own container — never bound after a render. A reused element keeps the listeners it was built with, so binding per render would stack them on the survivors and miss the rebuilt ones.

Work that the visitor is not waiting for does not run in the handler they are waiting on. The two directory lists sit below the grid, so a filter change schedules them with defer() and they fill a frame later; a view behind another tab is re-rendered through refreshViews(), which hands it to the same idle queue the boot renders use. Both keep the tap charged for what it changed on screen and nothing else.

For the rows that are rebuilt, the older rule still holds: a rebuild must not move the page. The visitor pressed a button that was on screen, so the view belongs where they left it when the press is over. Three things threaten that, and all three are handled where they arise rather than papered over afterwards:

  • focus() scrolls its element into view, and after a rebuild the element is new, so the browser has no memory of it and centres it. restoreFocus() in js/app.js puts focus back without the scroll, and reveals the control only when the focus ring is actually showing — the keyboard case, where invisible focus is the worse failure.
  • The browser's scroll anchoring holds a node in view to absorb content loading above it. A wholesale rebuild destroys that node, so it corrects for a shift that never happened; the rebuilt containers opt out with overflow-anchor: none.
  • Anything above a list has to be written before the list, not after: focus goes back mid-rebuild, and a header row that grows afterwards shoves the control focus just landed on off the screen.

One rule runs through the trade feature and is worth stating once: the "I have a trade badge" setting gates discovery, never resolution. It decides what the guide offers you. It never decides whether something you already saved still resolves — a booth tapped on the map or imported from a share link shows up in your plan with the setting off, because a saved thing quietly disappearing because of a preference is the one behaviour none of this is allowed to have. docs/PLAN-trade-exhibitors.md is the design record.

File Contents
data/exhibitors.json Array of exhibitors: location, games, tags, crowd forecast — structure only. type: "trade" cards are business-area booths and carry offers and an access value instead of a lineup and a queue index
data/directory.json The raw official exhibitor list (~1650 rows) with each row's product groups. Lazy-loaded — it backs the Full directory and the trade list. Generated by tools/fetch-directory.py
data/event.json Event structure: opening times, business-area hours, trade-day flags, areas, and the feedback block that turns the end-of-show form on
data/meta.json lastUpdated and revision — when the data last changed — plus lastChecked, the day the sources were last swept, which moves on every refresh whether or not it found anything. That is what lets a card nobody has had to correct in a week say so, instead of reading as a week stale
data/i18n/<lang>.json Every sentence a visitor reads, per language, keyed back to the files above
data/changelog.json Per-revision change notes, shown on the Updates tab (English only). Each bullet is tagged content, feature or fix — what the guide knows about the show, what it can do, what it was getting wrong — so "when did the game information last change" is one chip away rather than a read through every revision
data/hallplan/index.json, data/hallplan/hall-*.json Booth outlines per hall level, for the map. Generated by tools/fetch-hallplan.mjs from Koelnmesse's hall-plan data — booth numbers stay editorial in exhibitors.json, and the two are joined at load time
data/hallplan/outline.json How far each hall's wall stands off its booths, and where the doors in it are. Hand-written, and the one file in that directory the tool never touches: the official data files stand blocks and stands, never a wall or a doorway
data/hallplan/campus.json The whole site in one diagram — every hall in its place, the Boulevard between them, the passages and the gates — behind the map's Overview chip. Generated by the same tool from the hall-plan page's campus outlines, which are fitted to Koelnmesse's artwork rather than drawn to scale: right about where a hall is, wrong about how big it is, and it says so
worker/ The live queue API Worker (deployed separately from the site, see wrangler-api.toml), estimator, phone moderation page and tracked D1 migrations; never copied into dist/

Languages

The guide is English and German. The language is decided at load time — the ?lang parameter, then a stored preference, then the browser's own — and a switcher sits in the header and the footer. There are no /de/ URLs: one deploy, one service-worker scope, and a share link means the same thing whoever opens it.

That last-resort browser detection is also why ?lang=de has to be a real address rather than an implementation detail. A crawler arrives with no German preference and nothing stored, so left to itself it would see the English guide on every visit and never learn the German one exists. ?lang=de is the one address that pins the language for anyone, so both switchers are <a> elements pointing at it — js/i18n.js relabels them, keeps them on the view you are reading, and takes the click so a real visitor still gets the choice remembered rather than stuck in the URL. The <link rel="alternate" hreflang> set in index.html and map.html says the two are editions of each other, and sitemap.xml repeats the pairing.

Data and language are stored separately. The base files above hold ids, halls, booth numbers and flags; the prose lives in data/i18n/en.json and data/i18n/de.json, keyed by exhibitor id, game title, show date and area name. A reader fetches the base data plus one language, so nobody downloads prose they can't read. Weekday names aren't stored at all — they're formatted from the date in the reader's language.

Two things stay English on purpose: proper nouns (game titles, exhibitor and area names — game titles are also the identity behind saved marks and share links, so translating them would break every link in circulation), and the changelog, which is rewritten every few days. imprint.html stays English; privacy.html contains both languages because queue reporting is the first optional visitor action that reaches the server.

js/i18n.js is the whole runtime: language resolution, a ~45-line t() with interpolation and plurals, and a data-i18n pass over the static markup. tools/check-i18n.mjs enforces key, plural and placeholder parity, checks the prose still points at data that exists, and flags English that changed after its translation — tools/build-site.sh runs it before every deploy.

Search and link previews

Everything below the masthead is rendered from JSON after boot, so the first look any machine gets at this site is a header, a footer and no subject. Four things put that right, and all four are checked by tools/check-seo.mjs before a deploy — every one of them is hand-written about data that moves, none of it shows on screen, and a mistake in any of it looks exactly like a working site until it is somebody else's search result.

  • Canonical. Four hostnames serve this file (see wrangler.toml), and the canonical is what stops them competing as four copies. On ?lang=de it points at itself instead — a language edition that canonicals to the English page is one that gets dropped.
  • hreflang. The German guide's only route in; see above.
  • JSON-LD. An Event and a WebSite, so a crawler learns this is a listing for a five-day show in Cologne without running a script. Ticket prices are deliberately left out: the official shop is the only place that should be quoting those in a search result. The dates, name and venue are checked against data/event.json on every build.
  • Open Graph. The guide is passed around in queues and Discord servers far more than it is searched for, and every unfurler that renders those links is a server that never runs a line of the page — so the preview text is static English in the markup rather than data-i18n, and icons/og-cover.png (tools/make-screenshots.mjs) is the card image.

robots.txt is a plain allow-all pointing at the sitemap. sitemap.xml is generated into dist/ at deploy time by tools/make-sitemap.mjs, which reads its URLs back out of the staged pages' own hreflang links rather than keeping a second list to fall out of step.

See docs/UPDATING.md for the data schema and the periodic-refresh playbook (designed to be run by a scheduled Claude Code routine).

Licensing

Two licences, because the repository holds two different kinds of thing.

Licence
Codeindex.html, map.html, js/, css/, sw.js, worker/, test/, tools/ and Worker configuration MIT Take it, fork it for another show, no conditions beyond the notice
Datadata/ CC BY 4.0 Credit hallgui.de and say when you took it

The data licence covers the editorial layer — the selection, the confirmed/expected/rumored calls, the crowd forecasts, the sources and check dates, and the compilation as a database. It does not cover the underlying facts, which nobody owns, nor the generated data/hallplan/index.json, data/hallplan/hall-*.json and data/hallplan/campus.json, which come from Koelnmesse's published hall-plan data and are not this project's to relicense (outline.json beside them is hand-written and is covered — none of it came from there). data/README.md sets out the line in full.

If that split looks fussy for a fan project: it is what keeps the repository from claiming more than it can give. A single MIT over everything would have offered strangers the right to sublicense Koelnmesse's hall plans.

Disclaimer

Not affiliated with gamescom, Koelnmesse or game — Verband der deutschen Games-Branche. Game statuses are labeled: confirmed (officially announced for gamescom), expected (strongly implied), rumored (editorial guess). Crowd levels are estimates.

About

An unofficial web guide to gamescom 2026

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Sponsor this project

Contributors

Languages