Compare commits

..
4 Commits
Author SHA1 Message Date
lobo 8681508cb6 feat(projects): rewrite project texts with verified facts
Build, Test & Push Frontend / docker (push) Successful in 50s
Build, Test & Push Frontend / quality-check (push) Successful in 1m18s
Old copy was generic and did not match the code. Texts now describe
the real features: PlayStation sync, file imports, tests, security.
Add per-game itch.io links, new chips, and a test that checks every
About project key exists in en and de.
2026-10-14 11:03:54 +02:00
lobo 106d39453d fix(about): show original company logos again
Remove the logo invert added in 07a9b8b and its test.
The inverted logos were not wanted, so the originals show again.
2026-10-14 09:04:46 +02:00
lobo 07a9b8bc11 fix(site): remove canvas frame, invert logos, new footer text
The canvas had a 1px frame. The company logos are opaque white, so
invert them to match the dark tiles. Core fetches the Auth public key
(JWKS), so the To Be Played diagram now shows that arrow.
2026-10-14 08:49:00 +02:00
lobo 93e9c59436 feat(projects): add architecture diagrams, align project texts
Add diagrams to three projects. A tap opens them full size.
Align en/de texts: no versions, Gitea not GitHub, Portainer, home
server + VPS. Make To Be Played the featured project.
Remove the Hostinger logo image.
2026-10-14 08:18:18 +02:00
15 changed files with 517 additions and 208 deletions

No files matched your search

+3 -4
View File
@@ -41,12 +41,11 @@ export class AssetsConstants {
'/assets/projects/diploma/6.webp'
];
static readonly CLAUDE_BOX_IMAGES = [
'/assets/projects/claude-in-a-box/1.svg'
];
static readonly CLAUDE_BOX_DIAGRAM = '/assets/projects/claude-in-a-box/1.svg';
static readonly TBP_DIAGRAM = '/assets/projects/to-be-played/architecture.svg';
static readonly TRIBBLE_DIAGRAM = '/assets/projects/tribble-the-homeserver/architecture.svg';
static readonly TRIBBLE_IMAGES = [
'/assets/projects/tribble-the-homeserver/1.webp',
'/assets/projects/tribble-the-homeserver/2.webp',
'/assets/projects/tribble-the-homeserver/3.webp',
'/assets/projects/tribble-the-homeserver/4.webp'
+1 -1
View File
@@ -5,7 +5,7 @@
</main>
<footer class="border-t border-black/8 p-fluid-md text-center opacity-80 bg-app-bg">
<small>© {{ currentYear }} Andreas Dahm - {{ `APP.COPYRIGHT` | translate }}</small>
<small>© {{ currentYear }} Andreas Dahm. {{ 'APP.COPYRIGHT' | translate }}</small>
<br />
<small>
{{ 'APP.AI_NOTICE' | translate }}
+38
View File
@@ -0,0 +1,38 @@
import { TestBed } from '@angular/core/testing';
import { Component, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideTranslateService } from '@ngx-translate/core';
import { AppComponent } from './app.component';
import { TopbarComponent } from '../topbar/topbar.component';
import { ParticleBackgroundComponent } from '../../shared/components/particles-background/particles-background.component';
import { SeoService } from '../../service/seo.service';
@Component({ selector: 'app-topbar', template: '' })
class TopbarStub {}
@Component({ selector: 'app-particles-background', template: '' })
class ParticlesStub {}
describe('AppComponent', () => {
it('renders the copyright line with year, name and a closing dot', async () => {
TestBed.overrideComponent(AppComponent, {
remove: { imports: [TopbarComponent, ParticleBackgroundComponent] },
add: { imports: [TopbarStub, ParticlesStub] },
});
await TestBed.configureTestingModule({
imports: [AppComponent],
providers: [
provideZonelessChangeDetection(),
provideRouter([]),
provideTranslateService({ lang: 'en', fallbackLang: 'en' }),
{ provide: SeoService, useValue: { init: vi.fn() } },
],
}).compileComponents();
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const text = fixture.nativeElement.querySelector('footer small').textContent.trim();
expect(text).toBe(`© ${new Date().getFullYear()} Andreas Dahm. APP.COPYRIGHT`);
});
});
+32 -1
View File
@@ -5,6 +5,8 @@ import { MatIconRegistry } from '@angular/material/icon';
import { provideRouter } from '@angular/router';
import { provideTranslateService } from '@ngx-translate/core';
import { AboutComponent } from './about.component';
import en from '../../../assets/i18n/en.json';
import de from '../../../assets/i18n/de.json';
/**
* No translations are loaded, so the translate pipe echoes the key back. That is
@@ -151,6 +153,26 @@ describe('AboutComponent', () => {
});
describe('projects', () => {
it('every project key exists in en and de', () => {
const keys = fixture.componentInstance.projectKeys.flatMap(project => [
`${project.key}.TITLE`,
`${project.key}.DESCRIPTION`,
`${project.key}.LINK_INTERNAL`,
`${project.key}.HIGHLIGHTS.P1`,
`${project.key}.HIGHLIGHTS.P2`,
`${project.key}.HIGHLIGHTS.P3`,
...(project.externalLink ? [`${project.key}.LINK_EXTERNAL`] : []),
]);
const missing = Object.entries({ en, de }).flatMap(([language, translations]) =>
keys
.filter(key => typeof resolve(translations, key) !== 'string')
.map(key => `${language}: ${key}`)
);
expect(missing).toEqual([]);
});
it('lists To Be Played and links to its project dialog', () => {
expect(textOf('mat-card strong')).toContain('ABOUT.PROJECT.TBP.TITLE');
@@ -166,11 +188,14 @@ describe('AboutComponent', () => {
describe('education', () => {
it('shows only the post-school entries until the toggle button is clicked', () => {
expect(textOf('[data-testid="education-where"]')).toContain('ABOUT.EDUCATION.E6.WHERE');
expect(textOf('[data-testid="education-where"]')).not.toContain('ABOUT.EDUCATION.E0.WHERE');
expect(textOf('[data-testid="education-where"]')).not.toContain('ABOUT.EDUCATION.E2.WHERE');
expect(textOf('[data-testid="education-where"]').length).toBe(3);
click('toggle-education');
expect(textOf('[data-testid="education-where"]')).toContain('ABOUT.EDUCATION.E2.WHERE');
expect(textOf('[data-testid="education-where"]')).toContain('ABOUT.EDUCATION.E0.WHERE');
expect(textOf('[data-testid="education-where"]').length).toBe(7);
});
});
@@ -188,4 +213,10 @@ describe('AboutComponent', () => {
expect(fixture.debugElement.query(By.css('[data-testid="contact-mail"]'))).toBeTruthy();
});
});
function resolve(translations: unknown, key: string): unknown {
return key
.split('.')
.reduce<unknown>((node, part) => (node as Record<string, unknown> | undefined)?.[part], translations);
}
});
@@ -12,6 +12,14 @@
</ul>
</div>
@if (project.diagram) {
<div class="mb-4 rounded-xl overflow-hidden bg-[#1e2530]">
<a [href]="project.diagram" target="_blank" rel="noopener" [attr.aria-label]="'PROJECTS.DIAGRAM_ALT' | translate">
<img class="w-full h-auto block" [src]="project.diagram" [alt]="'PROJECTS.DIAGRAM_ALT' | translate">
</a>
</div>
}
<div class="grid grid-cols-[repeat(auto-fit,minmax(300px,1fr))] sm-dialog:grid-cols-1 gap-4 mb-4">
<div class="p-5 rounded-xl bg-black/3 border border-black/5 dark:bg-white/5 dark:border-white/10">
<div class="flex items-center gap-3 mb-3 text-link">
@@ -1,4 +1,4 @@
import { TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { provideTranslateService } from '@ngx-translate/core';
@@ -27,27 +27,46 @@ describe('ProjectDialogComponent', () => {
expect(() => register()).not.toThrow();
});
it('renders an upgraded swiper-container for project images', async () => {
async function createDialog(data: object): Promise<ComponentFixture<ProjectDialogComponent>> {
await TestBed.configureTestingModule({
imports: [ProjectDialogComponent],
providers: [
provideZonelessChangeDetection(),
provideTranslateService({ lang: 'en', fallbackLang: 'en' }),
{
provide: MAT_DIALOG_DATA,
useValue: {
title: 'T',
technologies: [],
images: [{ url: 'a.png' }, { url: 'b.png' }],
},
},
{ provide: MAT_DIALOG_DATA, useValue: data },
],
}).compileComponents();
const fixture = TestBed.createComponent(ProjectDialogComponent);
fixture.detectChanges();
return fixture;
}
it('renders an upgraded swiper-container for project images', async () => {
const fixture = await createDialog({
title: 'T',
technologies: [],
images: [{ url: 'a.png' }, { url: 'b.png' }],
});
const el: HTMLElement = fixture.nativeElement.querySelector('swiper-container');
expect(el).not.toBeNull();
expect(el instanceof customElements.get('swiper-container')!).toBe(true);
});
it('renders the diagram above the sections when the project has one', async () => {
const fixture = await createDialog({ title: 'T', technologies: [], images: [], diagram: 'd.svg' });
const img: HTMLImageElement | null = fixture.nativeElement.querySelector('img[alt="PROJECTS.DIAGRAM_ALT"]');
expect(img?.getAttribute('src')).toBe('d.svg');
const link: HTMLAnchorElement | null = img?.closest('a') ?? null;
expect(link?.getAttribute('href')).toBe('d.svg');
expect(link?.getAttribute('target')).toBe('_blank');
expect(fixture.nativeElement.querySelector('swiper-container')).toBeNull();
});
it('renders no diagram without one', async () => {
const fixture = await createDialog({ title: 'T', technologies: [], images: [] });
expect(fixture.nativeElement.querySelector('img[alt="PROJECTS.DIAGRAM_ALT"]')).toBeNull();
});
});
@@ -5,8 +5,8 @@
<mat-card-title>{{ project.title | translate }}</mat-card-title>
<mat-card-subtitle>{{ project.shortDescription | translate }}</mat-card-subtitle>
</mat-card-header>
@if(project.images.length > 0) {
<img mat-card-image class="w-full h-[clamp(150px,25vw,250px)] object-cover" [src]="project.images[0].url" [alt]="project.title | translate">
@if (cardImage(project); as imageUrl) {
<img mat-card-image class="w-full h-[clamp(150px,25vw,250px)] object-cover" [src]="imageUrl" [alt]="project.title | translate">
} @else {
<div class="flex justify-center items-center h-[clamp(150px,20vw,200px)] bg-[#f0f0f0]">
<mat-icon class="text-[clamp(3rem,8vw,4rem)] w-[clamp(3rem,8vw,4rem)] h-[clamp(3rem,8vw,4rem)] text-[#666]">{{ project.icon }}</mat-icon>
@@ -31,8 +31,8 @@
<mat-card-header class="pb-4">
<mat-card-title>{{ project.title | translate }}</mat-card-title>
</mat-card-header>
@if(project.images.length > 0) {
<img mat-card-image class="w-full h-[clamp(150px,25vw,250px)] object-cover" [src]="project.images[0].url" [alt]="project.title | translate">
@if (cardImage(project); as imageUrl) {
<img mat-card-image class="w-full h-[clamp(150px,25vw,250px)] object-cover" [src]="imageUrl" [alt]="project.title | translate">
} @else {
<div class="flex justify-center items-center h-[clamp(150px,20vw,200px)] bg-[#f0f0f0]">
<mat-icon class="text-[clamp(3rem,8vw,4rem)] w-[clamp(3rem,8vw,4rem)] h-[clamp(3rem,8vw,4rem)] text-[#666]">{{ project.icon }}</mat-icon>
@@ -36,6 +36,14 @@ describe('ProjectsComponent', () => {
expect(titles).toContain('PROJECTS.TBP.TITLE');
});
it('features To Be Played', () => {
expect(fixture.componentInstance.featuredProject()?.identifier).toBe('to-be-played');
});
it('card falls back to the diagram when a project has no images', () => {
expect(fixture.nativeElement.querySelector('img[src="/assets/projects/claude-in-a-box/1.svg"]')).not.toBeNull();
});
it('every project translation key exists in en and de', () => {
const keys = fixture.componentInstance.allProjects.flatMap(project => [
project.title,
+34 -14
View File
@@ -19,6 +19,7 @@ export interface Projects {
url: string,
source: string
}[],
diagram?: string,
icon: string,
assets: string,
links: {
@@ -59,6 +60,7 @@ export class ProjectsComponent implements OnInit, OnDestroy {
shortDescription: 'PROJECTS.TBP.SHORT_DESCRIPTION',
introduction: 'PROJECTS.TBP.INTRODUCTION',
images: AssetsConstants.TBP_IMAGES.map(url => ({ url, source: '' })),
diagram: AssetsConstants.TBP_DIAGRAM,
icon: 'playlist_add_check',
assets: '',
links: [
@@ -72,17 +74,19 @@ export class ProjectsComponent implements OnInit, OnDestroy {
'PROJECTS.TBP.BULLET_2',
'PROJECTS.TBP.BULLET_3',
'PROJECTS.TBP.BULLET_4',
'PROJECTS.TBP.BULLET_5',
],
challenges: [
'PROJECTS.TBP.CHALLENGE_1',
'PROJECTS.TBP.CHALLENGE_2',
'PROJECTS.TBP.CHALLENGE_3',
'PROJECTS.TBP.CHALLENGE_4',
],
learnings: [
'PROJECTS.TBP.LEARNING_1',
'PROJECTS.TBP.LEARNING_2',
],
isFeatured: false,
isFeatured: true,
technologies: ['Java', 'Spring Boot', 'PostgreSQL', 'React', 'Tailwind CSS', 'Docker']
},
{
@@ -90,7 +94,8 @@ export class ProjectsComponent implements OnInit, OnDestroy {
title: 'PROJECTS.CLAUDE_BOX.TITLE',
shortDescription: 'PROJECTS.CLAUDE_BOX.SHORT_DESCRIPTION',
introduction: 'PROJECTS.CLAUDE_BOX.INTRODUCTION',
images: AssetsConstants.CLAUDE_BOX_IMAGES.map(url => ({ url, source: '' })),
images: [],
diagram: AssetsConstants.CLAUDE_BOX_DIAGRAM,
icon: 'smart_toy',
assets: '',
links: [],
@@ -126,6 +131,7 @@ export class ProjectsComponent implements OnInit, OnDestroy {
'PROJECTS.PLAYGROUND.BULLET_2',
'PROJECTS.PLAYGROUND.BULLET_3',
'PROJECTS.PLAYGROUND.BULLET_4',
'PROJECTS.PLAYGROUND.BULLET_5',
],
challenges: [
'PROJECTS.PLAYGROUND.CHALLENGE_1',
@@ -136,7 +142,7 @@ export class ProjectsComponent implements OnInit, OnDestroy {
'PROJECTS.PLAYGROUND.LEARNING_2',
],
isFeatured: false,
technologies: ['Angular', 'TypeScript', 'SCSS', 'HTML', 'GitHub Actions', 'Docker']
technologies: ['Angular', 'TypeScript', 'Tailwind CSS', 'WebGPU', 'GLSL', 'Babylon.js', 'Gitea Actions', 'Docker']
},
{
identifier: "elmucho",
@@ -162,8 +168,8 @@ export class ProjectsComponent implements OnInit, OnDestroy {
'PROJECTS.EL_MUCHO.LEARNING_1',
'PROJECTS.EL_MUCHO.LEARNING_2',
],
isFeatured: true,
technologies: ['Unity', 'C#', 'Steamworks', 'Git']
isFeatured: false,
technologies: ['Unity', 'C#', 'Steamworks']
},
{
identifier: "gamejams",
@@ -173,7 +179,12 @@ export class ProjectsComponent implements OnInit, OnDestroy {
images: AssetsConstants.GAME_JAMS_IMAGES.map(url => ({ url, source: '' })),
icon: 'videogame_asset',
assets: '',
links: [{ name: 'PROJECTS.LINK_TO_PROJECT', url: 'https://itch.io/c/6628860/lobos-collection' }],
links: [
{ name: 'Peter Blue and the carnival of death', url: 'https://lobothedark.itch.io/beansjam' },
{ name: 'Arkonauts', url: 'https://lobothedark.itch.io/arkonauts' },
{ name: 'Sloth Bros', url: 'https://lobothedark.itch.io/sloth-bros' },
{ name: 'PROJECTS.GAME_JAMS.LINK_COLLECTION', url: 'https://itch.io/c/6628860/lobos-collection' }
],
bulletPoints: [
'PROJECTS.GAME_JAMS.BULLET_1',
'PROJECTS.GAME_JAMS.BULLET_2',
@@ -189,7 +200,7 @@ export class ProjectsComponent implements OnInit, OnDestroy {
'PROJECTS.GAME_JAMS.LEARNING_2',
],
isFeatured: false,
technologies: ['Unity', 'C#', 'Git']
technologies: ['Unity', 'C#']
},
{
identifier: "diploma",
@@ -205,17 +216,19 @@ export class ProjectsComponent implements OnInit, OnDestroy {
'PROJECTS.DIPLOMA.BULLET_2',
'PROJECTS.DIPLOMA.BULLET_3',
'PROJECTS.DIPLOMA.BULLET_4',
'PROJECTS.DIPLOMA.BULLET_5',
],
challenges: [
'PROJECTS.DIPLOMA.CHALLENGE_1',
'PROJECTS.DIPLOMA.CHALLENGE_2',
'PROJECTS.DIPLOMA.CHALLENGE_3',
],
learnings: [
'PROJECTS.DIPLOMA.LEARNING_1',
'PROJECTS.DIPLOMA.LEARNING_2',
],
isFeatured: false,
technologies: ['Java', 'Performance', 'Algorithm', 'Simulation']
technologies: ['Java', 'BVH / k-DOP', 'Collision detection', 'Cloth simulation']
},
{
identifier: "tribble-the-homeserver",
@@ -223,28 +236,31 @@ export class ProjectsComponent implements OnInit, OnDestroy {
shortDescription: 'PROJECTS.TRIBBLE.SHORT_DESCRIPTION',
introduction: 'PROJECTS.TRIBBLE.INTRODUCTION',
images: [
{ url: AssetsConstants.TRIBBLE_IMAGES[0], source: 'https://upload.wikimedia.org/wikipedia/commons/0/03/Hostinger_Logo.png' },
{ url: AssetsConstants.TRIBBLE_IMAGES[1], source: 'https://dashboardicons.com/icons/docker-engine' },
{ url: AssetsConstants.TRIBBLE_IMAGES[2], source: 'https://dashboardicons.com/icons/gitea' },
{ url: AssetsConstants.TRIBBLE_IMAGES[3], source: 'https://dashboardicons.com/icons/traefik' }
{ url: AssetsConstants.TRIBBLE_IMAGES[0], source: 'https://dashboardicons.com/icons/docker-engine' },
{ url: AssetsConstants.TRIBBLE_IMAGES[1], source: 'https://dashboardicons.com/icons/gitea' },
{ url: AssetsConstants.TRIBBLE_IMAGES[2], source: 'https://dashboardicons.com/icons/traefik' }
],
diagram: AssetsConstants.TRIBBLE_DIAGRAM,
icon: 'dns',
assets: '',
links: [
{ name: 'Ubuntu Server', url: 'https://ubuntu.com/server' },
{ name: 'Docker', url: 'https://www.docker.com/' },
{ name: 'Portainer', url: 'https://www.portainer.io/' },
{ name: 'Traefik', url: 'https://traefik.io/' },
{ name: 'Gitea', url: 'https://gitea.io/' },
{ name: 'Jellyfin', url: 'https://jellyfin.org/' },
{ name: 'AdGuard Home', url: 'https://adguard.com/en/adguard-home/overview.html' },
{ name: 'Paperless-ngx', url: 'https://paperless-ngx.com/' },
{ name: 'Tailscale', url: 'https://tailscale.com/' }
{ name: 'Tailscale', url: 'https://tailscale.com/' },
{ name: 'Watchtower', url: 'https://containrrr.dev/watchtower/' }
],
bulletPoints: [
'PROJECTS.TRIBBLE.BULLET_1',
'PROJECTS.TRIBBLE.BULLET_2',
'PROJECTS.TRIBBLE.BULLET_3',
'PROJECTS.TRIBBLE.BULLET_4',
'PROJECTS.TRIBBLE.BULLET_5',
],
challenges: [
'PROJECTS.TRIBBLE.CHALLENGE_1',
@@ -255,13 +271,17 @@ export class ProjectsComponent implements OnInit, OnDestroy {
'PROJECTS.TRIBBLE.LEARNING_2',
],
isFeatured: false,
technologies: ['Ubuntu Server', 'Docker', 'Traefik', 'Gitea', 'Jellyfin', 'AdGuard Home', 'Paperless-ngx', 'Tailscale']
technologies: ['Ubuntu Server', 'Docker', 'Portainer', 'Traefik', 'Gitea', 'Jellyfin', 'AdGuard Home', 'Paperless-ngx', 'Tailscale', 'Watchtower']
}
]
featuredProject = computed(() => this.allProjects.find(p => p.isFeatured));
otherProjects = computed(() => this.allProjects.filter(p => !p.isFeatured));
cardImage(project: Projects): string | undefined {
return project.images[0]?.url ?? project.diagram;
}
ngOnInit(): void {
window.scrollTo({ top: 0, behavior: 'smooth' });
setTimeout(() => { this.dialogOpenFunction(); }, 10);
@@ -7,3 +7,7 @@
z-index: -1;
pointer-events: none;
}
canvas {
box-shadow: none;
}
+95 -87
View File
@@ -1,7 +1,7 @@
{
"APP": {
"TITLE": "Playground",
"COPYRIGHT": "Bilder und Sourcecode sind urheberrechtlich geschützt, keine Nutzung ohne Zustimmung!",
"COPYRIGHT": "Alle Rechte vorbehalten.",
"AI_NOTICE": "Entstanden mit einem KI-gestützten Engineering-Workflow.",
"AI_NOTICE_LINK": "Wie ich mit KI arbeite"
},
@@ -187,13 +187,13 @@
"PROJECT": {
"TBP": {
"TITLE": "To Be Played",
"DESCRIPTION": "Ein Steam-Backlog-Tracker, der meine Bibliothek importiert, Status, Bewertung und Notizen verwaltet und passend zur verfügbaren Zeit vorschlägt, was ich als Nächstes spiele. Drei selbst gehostete Dienste: Auth, Core und ein React-Frontend.",
"DESCRIPTION": "Ein Backlog-Tracker für Steam und PlayStation, der meine Bibliotheken synchronisiert, Status, Bewertung und Notizen verwaltet und passend zur verfügbaren Zeit vorschlägt, was ich als Nächstes spielen kann. Drei selbst gehostete Dienste: Auth, Core und ein React-Frontend.",
"LINK_EXTERNAL": "tbp.andreas-dahm.eu",
"LINK_INTERNAL": "Projektdetails",
"HIGHLIGHTS": {
"P1": "Steam-Sync von Spielen, Spielzeit, Erfolgen und Wunschliste, dazu die Spieldauer (Time to Beat) aus IGDB.",
"P2": "Vorschläge für „Was spiele ich als Nächstes?“, gefiltert nach Zeitbudget und Stimmung.",
"P3": "Der Auth-Service signiert die Tokens mit Ed25519, Core kennt nur den öffentlichen Schlüssel."
"P1": "Steam- und PlayStation-Sync von Spielen, Spielzeit, Erfolgen und Trophäen, dazu die Spieldauer (Time to Beat) aus IGDB.",
"P2": "„Was spiele ich als Nächstes?“: sieben Reihen mit Vorschlägen, gefiltert nach Zeitbudget; ein Stimmungsfilter für die Zufallsvorschläge.",
"P3": "Auth signiert die Tokens mit Ed25519; Core holt den öffentlichen Schlüssel über die Key-ID und prüft nur."
}
},
"CLAUDE_BOX": {
@@ -208,55 +208,55 @@
},
"P2": {
"TITLE": "Playground",
"DESCRIPTION": "Dieses Projekt ist, wie der Name schon sagt, ein persönlicher Playground, der im Laufe der Zeit wachsen soll. Es präsentiert verschiedene private Projekte, an denen ich arbeite, und dokumentiert deren Fortschritt, zentrale Ideen und wichtige Meilensteine.",
"DESCRIPTION": "Diese Seite. Ein Portfolio und ein Ort, um Algorithmen im Browser auszuprobieren: Pfadfindung, Sortieren, Fraktale in GLSL sowie eine Stoff- und eine Doppelpendel-Simulation als WebGPU-Compute-Shader. Angular, Deutsch und Englisch, gebaut und ausgeliefert von meiner eigenen CI.",
"LINK_EXTERNAL": "andreas-dahm.eu",
"LINK_INTERNAL": "Projektdetails",
"HIGHLIGHTS": {
"P1": "Einsatz moderner Technologien und CI/CD-Pipelines (Angular 20+, Spring Boot 4, GitHub).",
"P2": "Präsentation persönlicher Projekte und kontinuierliche Verbesserung algorithmischer Fähigkeiten.",
"P3": "Vertiefung von JavaScript/TypeScript-, Angular- und Spring-Boot-Kenntnissen durch praktisches Arbeiten."
"P1": "Visualisierungen von Dijkstra bis zu 3D-Fraktalen per Raymarching, alles im Client.",
"P2": "Zwei WebGPU-Compute-Simulationen (Stoff mit XPBD, Doppelpendel) mit CPU- oder WebGL-Fallback.",
"P3": "Pipeline auf Gitea Actions: Lint, i18n-Prüfung, Tests, Lighthouse, Docker-Image in meine eigene Registry."
}
},
"P1": {
"TITLE": "El Mucho",
"DESCRIPTION": "Dies ist mein erstes veröffentlichtes Spiel auf Steam. Ich habe es gemeinsam mit meinem Bruder entwickelt und damit eines meiner persönlichen Ziele erreicht: ein eigenes Videospiel zu erstellen und zu veröffentlichen.",
"DESCRIPTION": "Mein erstes veröffentlichtes Spiel, ein rundenbasiertes taktisches Rollenspiel, das ich mit meinem Bruder in etwa drei Jahren gemacht und 2022 auf Steam veröffentlicht habe. Er hat es entworfen, ich habe es gebaut.",
"LINK_EXTERNAL": "Steam Store",
"LINK_INTERNAL": "Projektdetails",
"HIGHLIGHTS": {
"P1": "Veröffentlichung eines Spiels auf Steam und Integration der Steam-API.",
"P2": "Konzeption, Planung und vollständige Entwicklung eines eigenen Spiels.",
"P3": "Implementierung komplexer Algorithmen wie einer eigenen A*-Pfadfindungslogik und Spiel-KI."
"P1": "Der gesamte Code in Unity (C#), die Builds, das Steam-Setup und der Release.",
"P2": "16 Missionen, Erfolge, drei Sprachen (EN, DE, ES).",
"P3": "Eine eigene KI für die gegnerischen Einheiten und mein eigenes A*-Pathfinding auf dem Kartenraster."
}
},
"P0": {
"TITLE": "Game Jams",
"DESCRIPTION": "Dieser Bereich zeigt einige meiner bisherigen Game-Jam-Beiträge. Game Jams sind eine großartige Möglichkeit, neue Spielideen auszuprobieren und schnell zu überprüfen, ob ein Game-Loop funktioniert. Die engen Zeitvorgaben fördern den Fokus, es ist faszinierend, was man in so kurzer Zeit alles schaffen kann.",
"DESCRIPTION": "Drei 48-Stunden-Jam-Spiele aus der BeansJam-Reihe, in Teams aus zwei oder drei Leuten. Ich war jedes Mal Programmierer und Leiter. Alle sind auf itch.io spielbar.",
"LINK_EXTERNAL": "Itch.io Sammlung",
"LINK_INTERNAL": "Projektdetails",
"HIGHLIGHTS": {
"P1": "Planung eines realistischen Projektumfangs mit einem Team, der innerhalb von 48 Stunden umsetzbar ist.",
"P2": "Lernen, fokussiert und effizient unter strengen Zeitvorgaben zu arbeiten.",
"P3": "Die Freude zu erleben, in kurzer Zeit ein spielbares Projekt zu erstellen und andere damit spielen zu sehen."
"P1": "Peter Blue and the carnival of death (2018), Arkonauts (2020), Sloth Bros (2022).",
"P2": "Freitag bis Sonntag: jedes Mal ein spielbares Spiel in 48 Stunden.",
"P3": "Ein günstiger Weg, herauszufinden, ob eine Spielidee funktioniert, bevor man Jahre in sie steckt."
}
},
"DIPLOMA": {
"TITLE": "Diplomarbeit - Kollisionserkennung und Behandlung von komplexen Kleidungsstücken.",
"DESCRIPTION": "Die Diplomarbeit handelt von der Erkennung und der Behandlung von Kollisionen zwischen, sowie innerhalb, einzelnen Kleidungsstücken in Echtzeit. Das ist gerade aufgrund der Flexibilität von Stoffen und deren unterschiedlichen Eigenschaften besonders herausfordernd.",
"DESCRIPTION": "Selbstdurchdringungen innerhalb eines Kleidungsteils, etwa bei einem Plisseerock, in der Stoffsimulation Vidya bei assyst. Ich habe Intersection Contour Minimization an Vidyas k-DOP-Hierarchie angepasst und die Antwort um Masse und Geschwindigkeit erweitert. FH Bingen, März 2010.",
"LINK_INTERNAL": "Projektdetails",
"HIGHLIGHTS": {
"P1": "Echtzeit behandlung von Kollisionserkennung und Behandlung.",
"P2": "Verstehen und Einschätzen von wissenschaftlichen Arbeiten.",
"P3": "Adaption und Weiterentwicklung von vorausgegangenen Forschungsarbeiten."
"P1": "Erkennung auf der vorhandenen BVH mit k-DOPs; eine gemeinsame Hierarchie hat die Zeit für Update und Traversierung halbiert.",
"P2": "Vier veröffentlichte Methoden verglichen und die globale Variante der Intersection Contour Minimization gewählt.",
"P3": "Etwa 20 % schneller als der vorhandene Selbstkollisions-Detektor plus Layering, für den produktiven Einsatz vorgesehen."
}
},
"TRIBBLE": {
"TITLE": "Homeserver 'Tribble'",
"DESCRIPTION": "In diesem Projekt geht es um die Einrichtung und Wartung meines eigenen Homeservers. Er betreibt mehrere Docker-Container wie Gitea, Jellyfin and more. Es ist eine großartige Lernerfahrung im Bereich Self-Hosting und Systemadministration.",
"DESCRIPTION": "Ein Mini-PC zu Hause betreibt Gitea mit Actions-Runner und Registry, dazu Jellyfin, Paperless-ngx und AdGuard Home. Ein VPS liefert die öffentlichen Seiten aus und holt neue Images mit Watchtower über Tailscale. Läuft seit Mai 2024, Backups auf Google Drive.",
"LINK_INTERNAL": "Projektdetails",
"HIGHLIGHTS": {
"P1": "Self-Hosting verschiedener Dienste mit Docker.",
"P2": "CI/CD-Pipeline für die persönliche Website mit Gitea.",
"P3": "Sicherer Fernzugriff mit Tailscale und Traefik."
"P1": "Gitea-CI baut und testet diese Seite und To Be Played zu Hause; Watchtower spielt die Images auf dem VPS ein.",
"P2": "Traefik für TLS und Routing, Tailscale zwischen den beiden Maschinen.",
"P3": "Schwierig waren DNS und ein wiederholbares Setup, nicht die Dienste selbst."
}
}
},
@@ -301,6 +301,7 @@
"PROJECTS": {
"DOWNLOAD": "Herunterladen",
"READ_MORE": "Mehr erfahren",
"DIAGRAM_ALT": "Architekturdiagramm",
"LINK_TO_PROJECT": "Zum Projekt",
"CLOSE": "Schließen",
"SECTION": {
@@ -308,70 +309,75 @@
"LEARNINGS": "Learnings & Soft Skills"
},
"PLAYGROUND": {
"TITLE": "Playground Portfolio",
"SHORT_DESCRIPTION": "Full-Stack Portfolio mit interaktiven Algorithmus-Visualisierungen.",
"INTRODUCTION": "Diese Website dient als lebendiges Portfolio und Testumgebung für moderne Webtechnologien. Ziel ist es, komplexe Algorithmen und mathematische Konzepte (wie WebGPU-Simulationen oder Raymarching) anschaulich im Browser darzustellen.",
"BULLET_1": "Entwicklung mit Angular 19+ und Material Design.",
"BULLET_2": "Implementierung performanter Visualisierungen (WebGPU, Shader, Canvas).",
"BULLET_3": "Automatisierte CI/CD-Pipelines und Containerisierung mit Docker.",
"BULLET_4": "Internationalization (i18n) für globale Reichweite.",
"CHALLENGE_1": "Optimierung der Render-Performance bei komplexen 3D-Fraktalen in Echtzeit.",
"CHALLENGE_2": "Architektur einer skalierbaren und wartbaren Frontend-Struktur für diverse Sub-Projekte.",
"LEARNING_1": "Effektives UI/UX-Design für komplexe datengesteuerte Visualisierungen.",
"LEARNING_2": "Modernstes State-Management und reaktive Programmierung in Angular."
"TITLE": "Playground",
"SHORT_DESCRIPTION": "Diese Seite: ein Portfolio mit Algorithmus-Visualisierungen, die im Browser laufen, einige davon auf der GPU.",
"INTRODUCTION": "Auf dieser Seite probiere ich Dinge aus. Ich wollte sehen, wie weit ein normaler Browser mit den Algorithmen kommt, die ich mag. Deshalb läuft jede Visualisierung im Client: Pfadfindung, Labyrinth-Erzeugung, Sortieren, Conways Spiel des Lebens, Fraktale in GLSL-Shadern und zwei Physiksimulationen, die als Compute Shader auf WebGPU laufen. Der Rest ist eine normale Angular-App mit deutschen und englischen Texten, einem Dark Mode und einer CI-Pipeline, die sie baut, testet und als Docker-Image in meine eigene Registry schiebt.",
"BULLET_1": "Pfadfindung mit Dijkstra und A*, Labyrinth-Erzeugung mit Prim und Kruskal, fünf Sortieralgorithmen mit Ton, Conways Spiel des Lebens und eine Vier-Farben-Karte.",
"BULLET_2": "2D-Fraktale (Mandelbrot, Julia, Burning Ship, Newton) in einem GLSL-Shader und 3D-Fraktale (Mandelbulb, Mandelbox, Julia-Bulb) per Raymarching, mit Normalen aus dem Distanzfeld.",
"BULLET_3": "Ein Doppelpendel und eine Stoffsimulation laufen als Compute Shader auf WebGPU. Ohne WebGPU weichen sie auf die CPU oder WebGL aus und sagen das in einer Snackbar.",
"BULLET_4": "Angular mit Material und Tailwind, Signals für den Zustand, Deutsch und Englisch und ein Service Worker (PWA).",
"BULLET_5": "CI auf Gitea Actions: Lint, i18n-Prüfung, Tests, Produktions-Build und ein Lighthouse-Bericht. Auf main baut sie ein Docker-Image und schiebt es in meine eigene Registry.",
"CHALLENGE_1": "Der Stoff-Solver auf der GPU. XPBD-Constraints, die sich einen Partikel teilen, lassen sich nicht parallel lösen, ohne dass es zu einem Race kommt. Ich gruppiere die Constraints per Graphfärbung in acht Phasen. So berühren sich innerhalb einer Phase nie zwei Constraints am selben Partikel, und jede Phase läuft voll parallel.",
"CHALLENGE_2": "Nicht jeder Browser hat WebGPU. Beide Simulationen haben einen CPU- oder WebGL-Pfad und eine Snackbar, die sagt, dass der Fallback aktiv ist. So funktioniert die Seite überall, und niemand fragt sich, warum es langsamer ist.",
"LEARNING_1": "Compute Shader verändern, wie man über einen Algorithmus denkt. Das Datenlayout und die Reihenfolge der Schreibzugriffe zählen mehr als die Mathematik.",
"LEARNING_2": "Eine kleine CI mit einer Schlüssel-Prüfung für die Übersetzungen und einem Lighthouse-Lauf fängt die langweiligen Fehler ab, eine fehlende Übersetzung oder eine langsame Seite, bevor ich sie auf der Live-Seite sehe."
},
"TRIBBLE": {
"TITLE": "Self-Hosted Infrastructure",
"SHORT_DESCRIPTION": "Home-Infrastruktur mit Docker, Traefik und sicherer VPN-Anbindung.",
"INTRODUCTION": "Dokumentation und Aufbau einer privaten Cloud-Infrastruktur. Fokus liegt auf Datensouveränität, Automatisierung und Sicherheit.",
"BULLET_1": "Zentrale Verwaltung via Docker-Compose und Portainer.",
"BULLET_2": "Automatisches SSL-Management und Reverse-Proxy mit Traefik.",
"BULLET_3": "Private Versionskontrolle (Gitea) und Medien-Streaming (Jellyfin).",
"BULLET_4": "Netzwerkweite Ad-Blocking und DNS-Kontrolle via AdGuard Home.",
"CHALLENGE_1": "Konfiguration sicherer Netzwerkschichten und Firewall-Regeln für Remote-Zugriff.",
"CHALLENGE_2": "Automatisierung von Backups und Recovery-Strategien für containerisierte Daten.",
"LEARNING_1": "Tiefes Verständnis für moderne Netzwerkprotokolle und IT-Sicherheit.",
"LEARNING_2": "Effizientes Ressourcen-Management auf limitierten Server-Systemen."
"SHORT_DESCRIPTION": "Home-Server für CI und private Dienste, dazu ein VPS für die öffentlichen Seiten.",
"INTRODUCTION": "Zwei Maschinen. Zu Hause läuft auf einem Mini-PC namens „Tribble“ Gitea mit Actions-Runner und Container-Registry, dazu die privaten Dienste. Ein Hostinger-VPS liefert die öffentlichen Seiten aus, auch diese hier. Ein Git-Push baut das Image zu Hause, der VPS holt es mit Watchtower über Tailscale, und Traefik übernimmt TLS und Routing. Das Ganze läuft seit Mai 2024.",
"BULLET_1": "Hardware: ein KODLIX GD50 Mini-PC mit 8-Kern-Intel-Core-i5, 32 GB RAM und einer 512-GB-SSD.",
"BULLET_2": "Gitea für Git, den Actions-Runner und die Container-Registry. Diese Seite und To Be Played werden dort gebaut und getestet.",
"BULLET_3": "Private Dienste: Jellyfin für Medien, Paperless-ngx für Dokumente, AdGuard Home für DNS und Werbeblocker, Portainer, um die Container im Blick zu haben, und Traefik davor.",
"BULLET_4": "Tailscale verbindet die beiden Maschinen. Darüber holt der VPS neue Images aus der Registry zu Hause, und Watchtower tauscht den laufenden Container aus.",
"BULLET_5": "Die Backups gehen auf Google Drive.",
"CHALLENGE_1": "DNS. Namen müssen aus dem Haus, über Tailscale und aus dem öffentlichen Internet auf die richtige Adresse zeigen, und AdGuard Home sitzt mittendrin. Das richtig hinzubekommen hat länger gedauert als jeder einzelne Dienst.",
"CHALLENGE_2": "Alles gleichzeitig zum Laufen zu bringen und es dann wiederholbar zu machen. Das erste Setup war eine lange Liste von Handgriffen. Ich habe es automatisiert, damit sich der Server neu aufsetzen lässt, ohne dass ich mir jeden Schritt merken muss.",
"LEARNING_1": "Selbst hosten kostet wenig Geld und viel Zeit. Die meiste Zeit geht in die Teile, die niemand sieht: DNS, Zertifikate und Backups.",
"LEARNING_2": "Ein Git-Push, der ohne Handgriff dazwischen in einem laufenden Container auf dem VPS endet, ist der Teil dieses Setups, den ich nicht mehr hergeben würde."
},
"EL_MUCHO": {
"TITLE": "El Mucho (Steam Release)",
"SHORT_DESCRIPTION": "Rundenbasiertes Taktik-RPG, veröffentlicht auf Steam.",
"INTRODUCTION": "Ein kommerzielles Spieleprojekt, das von der ersten Idee bis zum weltweiten Release auf Steam eigenverantwortlich umgesetzt wurde. Ein taktisches RPG, das klassische Gameplay-Elemente mit modernen Systemen verbindet.",
"BULLET_1": "Komplette Engine-Entwicklung in Unity (C#).",
"BULLET_2": "Integration von Steamworks-Funktionen (Achievements, Cloud Saves).",
"BULLET_3": "Entwicklung einer eigenen taktischen KI und Pfadfindungs-Logik.",
"BULLET_4": "Management des gesamten Asset-Pipelines und Sound-Designs.",
"CHALLENGE_1": "Implementierung eines robusten rundenbasierten Systems mit komplexen Abhängigkeiten.",
"CHALLENGE_2": "Performance-Optimierung für eine flüssige Darstellung auf verschiedenen Hardware-Profilen.",
"CHALLENGE_3": "Umgang mit den strengen Zertifizierungs-Anforderungen von Steam.",
"LEARNING_1": "Durchhaltevermögen und Fokus über einen mehrjährigen Entwicklungszyklus.",
"LEARNING_2": "Vermarktung und Community-Management für ein digitales Produkt."
"SHORT_DESCRIPTION": "Ein rundenbasiertes taktisches Rollenspiel, das mein Bruder und ich auf Steam veröffentlicht haben.",
"INTRODUCTION": "El Mucho ist ein rundenbasiertes taktisches Rollenspiel in einer erfundenen Welt namens Liberika, inspiriert von Langrisser. Mein Bruder und ich haben es in etwa drei Jahren neben einem Vollzeitjob gebaut und auf Steam veröffentlicht: Early Access im Juli 2021, die Vollversion im Februar 2022. Die Aufteilung war klar. Er hatte die Idee und hat die Assets, das Leveldesign, das Testen und das Balancing gemacht. Ich habe alles Technische gemacht: den Code in Unity, die Builds, das Steam-Setup und den Release.",
"BULLET_1": "Unity und C#. Ein Entwickler für den gesamten Code.",
"BULLET_2": "16 Missionen, Erfolge und Familienfreigabe auf Steam, veröffentlicht als Lobo Corp.",
"BULLET_3": "Englisch, Deutsch und Spanisch. Die spanische Übersetzung hat ein Freund gemacht, der Muttersprachler ist.",
"BULLET_4": "Grafik und Musik sind teils Open Source, teils von meinem Bruder, teils von mir.",
"CHALLENGE_1": "Der Steam-Release als Ein-Personen-Technikteam. Builds, Store-Einrichtung, Upload und die Early-Access-Phase liefen alle über mich. Early Access startete im Juli 2021, die Vollversion folgte im Februar 2022.",
"CHALLENGE_2": "Drei Sprachen. Jeden Text gibt es auf Englisch, Deutsch und Spanisch; für Spanisch habe ich mit einem Freund zusammengearbeitet, der Muttersprachler ist.",
"CHALLENGE_3": "Die gegnerischen Einheiten. Dafür habe ich eine eigene KI und mein eigenes A*-Pathfinding auf dem Kartenraster geschrieben.",
"LEARNING_1": "Fertigmachen dauert länger als Bauen. Allein die Strecke vom Early Access bis zur Vollversion hat etwa sieben Monate gedauert.",
"LEARNING_2": "Eine klare Rollenverteilung hat ein Zwei-Personen-Team drei Jahre lang arbeitsfähig gehalten: Er war für das Spiel zuständig, ich für den Code und den Release."
},
"GAME_JAMS": {
"TITLE": "Rapid Prototyping & Game Jams",
"SHORT_DESCRIPTION": "Sammlung innovativer Spielkonzepte, entstanden in unter 48 Stunden.",
"INTRODUCTION": "Teilnahme an nationalen Wettbewerben (z.B. Beansjam). Hier geht es darum, unter extremem Zeitdruck funktionale und spaßige Prototypen zu erschaffen.",
"BULLET_1": "Fokus on 'Core Game Loop' und schnelles Feedback.",
"BULLET_2": "Kollaborative Entwicklung in kleinen, agilen Teams.",
"BULLET_3": "Effektives Zeitmanagement und Scope-Kontrolle.",
"BULLET_4": "Veröffentlichung und Iteration basierend auf Community-Votings.",
"CHALLENGE_1": "Reduzierung komplexer Ideen auf ein in 48h umsetzbares Minimum Viable Product (MVP).",
"CHALLENGE_2": "Schnelle Fehlerdiagnose und Bugfixing unter massivem Zeitdruck.",
"LEARNING_1": "Radikale Priorisierung von Features ('Kill your darlings').",
"LEARNING_2": "Effektive Kommunikation und Entscheidungsfindung im Team-Stress."
"TITLE": "Game Jams",
"SHORT_DESCRIPTION": "Drei kleine Spiele, jedes in 48 Stunden von Freitag bis Sonntag entstanden.",
"INTRODUCTION": "Bei einer Game Jam bekommt man am Freitagabend ein Thema und soll bis Sonntag ein spielbares Spiel abliefern. Ich habe bisher drei gemacht, alle in der BeansJam-Reihe, in Teams aus zwei oder drei Leuten. Ich war immer Programmierer und Leiter; die anderen haben Ideen, Assets und Tests beigesteuert. Alle drei Spiele gibt es auf itch.io.",
"BULLET_1": "Peter Blue and the carnival of death (2018, BeansJam): meine erste Jam.",
"BULLET_2": "Arkonauts (2020, Mini Beans Jam 6): Space Invaders im Weltraum.",
"BULLET_3": "Sloth Bros (2022, BeansJam '22): Thema „wir müssen wach bleiben“, eines von 65 Spielen.",
"BULLET_4": "Immer 48 Stunden, von Freitag bis Sonntag, in einem Team aus zwei oder drei Leuten.",
"CHALLENGE_1": "Der Umfang. 48 Stunden sind nicht viel, und mit Schlaf sind es weniger. Am Freitag zu entscheiden, was wir nicht bauen, war in jeder Jam die wichtigste Entscheidung.",
"CHALLENGE_2": "Als einziger Programmierer. Alles, was sich das Team ausgedacht hat, ging durch meine Tastatur. Eine Idee musste also billig zu bauen sein, sonst wurde sie gestrichen.",
"LEARNING_1": "Eine spielbare Schleife am Samstag schlägt eine bessere Idee am Sonntag.",
"LEARNING_2": "In einer Jam finde ich heraus, ob eine Spielidee funktioniert, bevor ich Jahre in sie stecke. El Mucho hat drei Jahre gedauert; ein Jam-Spiel dauert ein Wochenende.",
"LINK_COLLECTION": "Alle Spiele auf itch.io"
},
"DIPLOMA": {
"TITLE": "Wissenschaftliche Diplomarbeit",
"SHORT_DESCRIPTION": "Echtzeit-Kollisionserkennung für komplexe, flexible 3D-Objekte.",
"INTRODUCTION": "Forschungsarbeit im Bereich Computergraphik. Entwicklung eines Algorithmus zur physikalisch korrekten Simulation von Stoffen und Kleidung in Echtzeit.",
"BULLET_1": "Mathematische Modellierung von Mass-Spring-Systemen.",
"BULLET_2": "Low-Level Programmierung mit Java.",
"BULLET_3": "Optimierung durch räumliche Datenstrukturen (AABB Trees, Bounding Spheres).",
"BULLET_4": "Wissenschaftliche Evaluation der Simulations-Präzision.",
"CHALLENGE_1": "Behandlung von 'Self-Collisions' bei hochauflösenden Meshes ohne Performance-Einbruch.",
"CHALLENGE_2": "Mathematische Stabilisierung der Integrationsverfahren bei hohen Krafteinwirkungen.",
"LEARNING_1": "Transfer von theoretischen Forschungsarbeiten in produktiven, performanten Code.",
"LEARNING_2": "Präzises Arbeiten und Dokumentation nach wissenschaftlichen Standards."
"TITLE": "Diplomarbeit: Selbstkollisionen bei Stoffen",
"SHORT_DESCRIPTION": "Selbstdurchdringungen in simulierter Kleidung erkennen und auflösen, entstanden bei assyst für die Stoffsimulation Vidya (2010).",
"INTRODUCTION": "Simulierter Stoff faltet sich in sich selbst. Ein Plisseerock durchdringt zum Beispiel sein eigenes Gewebe, und sobald zwei Teile eines Kleidungsstücks durcheinander gerutscht sind, kann eine normale Kollisionsantwort sie nicht mehr trennen. Kollisionen zwischen verschiedenen Lagen konnte Vidya schon; Selbstdurchdringungen innerhalb eines Kleidungsteils nicht. In meiner Diplomarbeit bei assyst habe ich Intersection Contour Minimization (Volino et al. 2006) an Vidya angepasst und so erweitert, dass die Antwort Masse und Geschwindigkeit berücksichtigt. Geschrieben in Java, im März 2010 an der FH Bingen abgeschlossen und für den produktiven Einsatz vorgesehen.",
"BULLET_1": "Die Erkennung läuft auf Vidyas vorhandener Bounding-Volume-Hierarchie mit k-DOPs. Bei Selbsttests überspringt die Traversierung die Tests an inneren Knoten, die sie nicht braucht.",
"BULLET_2": "Die Schnittkontur wird aus Segmenten aufgebaut. Ein räumlicher Hash verbindet sie auch dann, wenn Gleitkommafehler kleine Lücken lassen.",
"BULLET_3": "Die Antwort ist meine Erweiterung: Sie nutzt Masse und Geschwindigkeit, sodass das Ergebnis physikalisch plausibel ist und nicht nur ein geometrischer Schubs.",
"BULLET_4": "Der Parameter h0 wird aus der mittleren Dreiecksfläche abgeleitet und muss daher nicht von Hand eingestellt werden.",
"BULLET_5": "Die Methode teilt sich eine BVH mit Vidyas vorhandenem TriangleSelfCollisionDetector.",
"CHALLENGE_1": "Der Aufwand. Die Testszene hatte etwa 16.000 Partikel bei etwa 430 ms pro Schritt auf einem 2-GHz-Core-2-Duo. Eine gemeinsame BVH für meinen Detektor und den vorhandenen hat die Zeit für Update und Traversierung halbiert und den Overhead um etwa 80 % gesenkt. Zusätzlich zur vorhandenen Erkennung kostet die Methode etwa 5 %.",
"CHALLENGE_2": "Die Wahl der Methode. Ich habe Baraff (2003), Bridson (2002) und Volino (2000, 2006) verglichen und die globale Variante der Intersection Contour Minimization genommen, weil sie auch Durchdringungen auflöst, die schon bestehen.",
"CHALLENGE_3": "Bekannte Grenzen, dokumentiert: nahezu ebene Flächen und ein Vertex, der genau auf einer Dreiecksebene liegt, werden nicht behandelt.",
"LEARNING_1": "Paper richtig lesen. Das gleiche Problem hatte vier veröffentlichte Lösungen, und die nützliche Arbeit war zu verstehen, wo jede davon scheitert.",
"LEARNING_2": "Erst messen, dann behaupten. Die Methode allein war etwa 20 % schneller als der vorhandene Detektor plus Layering, und diese Zahl zählt nur, weil sie aus derselben Szene auf derselben Maschine stammt."
},
"CLAUDE_BOX": {
"TITLE": "Claude-in-a-Box",
@@ -389,17 +395,19 @@
},
"TBP": {
"TITLE": "To Be Played",
"SHORT_DESCRIPTION": "Ein Steam-Backlog-Tracker, der bei der Entscheidung hilft, was als Nächstes gespielt wird.",
"INTRODUCTION": "Meine Steam-Bibliothek hat weit mehr Spiele, als ich je durchspielen werde. To Be Played importiert die Bibliothek, verwaltet den Backlog mit Status, Bewertung und Notizen und schlägt passend zur verfügbaren Zeit vor, was ich als Nächstes spielen kann. Dahinter stehen drei Dienste: ein Auth-Service, der die Login-Tokens signiert, ein Core-Service für Bibliothek und Statistiken und ein React-Frontend. Alles läuft selbst gehostet auf demselben Server wie diese Seite.",
"BULLET_1": "Steam-Sync von Spielen, Spielzeit, Erfolgen und Wunschliste, von Hand und jede Nacht, dazu die Spieldauer (Time to Beat) aus IGDB.",
"BULLET_2": "„Was spiele ich als Nächstes?“: sieben Reihen mit Vorschlägen, etwa der nächste Teil einer Reihe, dasselbe Studio oder Spiele, die Leute durchgespielt haben, denen man folgt, gefiltert nach Zeitbudget und Stimmung.",
"SHORT_DESCRIPTION": "Ein Backlog-Tracker für Steam und PlayStation, der bei der Entscheidung hilft, was als Nächstes gespielt wird.",
"INTRODUCTION": "Meine Spielebibliothek hat weit mehr Spiele, als ich je durchspielen werde. To Be Played synchronisiert Steam und PlayStation, importiert Listen von GOG, Playnite, Grouvee und HowLongToBeat, verwaltet den Backlog mit Status, Bewertung und Notizen und schlägt passend zur verfügbaren Zeit vor, was ich als Nächstes spielen kann. Dahinter stehen drei Dienste: ein Auth-Service, der die Login-Tokens signiert, ein Core-Service für Bibliothek und Statistiken und ein React-Frontend. Alles läuft selbst gehostet auf demselben Server wie diese Seite.",
"BULLET_1": "Steam-Sync von Spielen, Spielzeit, Erfolgen und Wunschliste, PlayStation-Sync von Titeln, Spielzeit und Trophäen, von Hand und jede Nacht, dazu die Spieldauer (Time to Beat) aus IGDB.",
"BULLET_2": "„Was spiele ich als Nächstes?“: sieben Reihen mit je drei Vorschlägen, etwa der nächste Teil einer Reihe, dasselbe Studio oder Spiele, die Leute gespielt oder durchgespielt haben, denen man folgt. Ein Zeitbudget filtert jede Reihe; der Stimmungsfilter wirkt nur auf die Zufallsvorschläge.",
"BULLET_3": "Öffentliche Profile, anderen Spielern folgen mit einer Inbox zu ihrem Fortschritt und Pile Points für jedes Spiel, das vom Stapel kommt.",
"BULLET_4": "Deutsch und Englisch, Light- und Dark-Mode, auch auf dem Handy nutzbar. Auth, Core und Frontend werden jeweils als eigenes Docker-Image ausgeliefert.",
"CHALLENGE_1": "Login über zwei Dienste. Auth signiert die Tokens mit einem Ed25519-Schlüssel, Core kennt nur den öffentlichen Schlüssel und kann einen Login also prüfen, aber nie selbst ausstellen. Die Tokens liegen in HttpOnly-Cookies, kein Skript auf der Seite kann sie lesen.",
"CHALLENGE_2": "Die Limits von Steam. Jeder Steam-Aufruf ist rate-limitiert und wird bei Bedarf wiederholt, und ein Sync reserviert seinen Lauf zuerst in der Datenbank, damit nie zwei Syncs desselben Users gleichzeitig laufen.",
"BULLET_5": "Über 3.400 Tests in den drei Diensten, ArchUnit-Regeln, die die Schichten getrennt halten, und Testcontainers für die echte Datenbank. Die CI führt den vollständigen Build aus und taggt jedes Image mit dem Commit.",
"CHALLENGE_1": "Login über zwei Dienste. Auth signiert die Tokens mit einem Ed25519-Schlüssel, Core kennt nur den öffentlichen Schlüssel und kann einen Login also prüfen, aber nie selbst ausstellen. Core holt diesen Schlüssel zur Laufzeit von Auth, gesucht über die Key-ID, und die Tokens liegen in HttpOnly-Cookies, kein Skript auf der Seite kann sie lesen.",
"CHALLENGE_2": "Die Limits von Steam. Jeder Steam-Aufruf ist auf zwei pro Sekunde begrenzt und wird nur dort wiederholt, wo eine Wiederholung sicher ist (Spielebibliothek, Schema, Erfolge). Ein Sync reserviert seinen Lauf zuerst in der Datenbank, damit nie zwei Syncs desselben Users gleichzeitig laufen, und der nächtliche Sync berührt nur User, die in den letzten 14 Tagen aktiv waren.",
"CHALLENGE_3": "Ein Spiel vorzuschlagen ist schwerer, als es klingt. Jede Vorschlagsreihe braucht eigene Regeln, und die Listen hinter jeder Zahl müssen dieselben Regeln nutzen wie die Zahl selbst, damit ein Spiel überall gleich zählt.",
"LEARNING_1": "Die Aufteilung in Dienste lohnt sich nur mit einem klaren Vertrag. Die Token-Claims stehen in einer Vertragsdatei, gegen die beide Dienste testen, und ein Skript prüft, dass beide Kopien gleich sind.",
"LEARNING_2": "Auch ein Hobbyprojekt braucht die langweiligen Teile: Backups, Migrationen und eine feste Deploy-Reihenfolge. Sie haben mehr darüber entschieden, wie stabil es läuft, als jedes Feature."
"CHALLENGE_4": "Missbrauch beim Login. Zu viele fehlgeschlagene Logins bekommen ein 429 mit Retry-After. Auch eine unbekannte E-Mail zahlt für eine volle BCrypt-Prüfung, sodass die Antwortzeit nichts verrät. Ein Header-Schutz blockt CSRF, CORS erlaubt nur exakte Origins, und das PlayStation-Token wird verschlüsselt gespeichert.",
"LEARNING_1": "Die Aufteilung in Dienste lohnt sich nur mit einem klaren Vertrag. Die Token-Claims stehen in einer Vertragsdatei, gegen die beide Dienste testen, und ein Skript, das ich von Hand starte, prüft, dass beide Kopien gleich sind.",
"LEARNING_2": "Auch ein Hobbyprojekt braucht die langweiligen Teile: tägliche Datenbank-Dumps, die sieben Tage, vier Wochen und sechs Monate aufbewahrt werden, Migrationen und eine schriftlich festgehaltene Deploy-Reihenfolge. Sie haben mehr darüber entschieden, wie stabil es läuft, als jedes Feature."
}
},
"IMPRINT": {
+95 -87
View File
@@ -1,7 +1,7 @@
{
"APP": {
"TITLE": "Playground",
"COPYRIGHT": "Images and code protected by copyright, no use without permission!",
"COPYRIGHT": "All rights reserved.",
"AI_NOTICE": "Built with an AI-assisted engineering workflow.",
"AI_NOTICE_LINK": "How I build with AI"
},
@@ -187,13 +187,13 @@
"PROJECT": {
"TBP": {
"TITLE": "To Be Played",
"DESCRIPTION": "A Steam backlog tracker that imports my library, keeps status, rating and notes, and suggests what to play next for the time I have. Three self-hosted services: auth, core and a React frontend.",
"DESCRIPTION": "A backlog tracker for Steam and PlayStation that syncs my libraries, keeps status, rating and notes, and suggests what to play next for the time I have. Three self-hosted services: auth, core and a React frontend.",
"LINK_EXTERNAL": "tbp.andreas-dahm.eu",
"LINK_INTERNAL": "Project details",
"HIGHLIGHTS": {
"P1": "Steam sync of games, playtime, achievements and wishlist, plus time to beat from IGDB.",
"P2": "\"What to play next\" suggestions filtered by time budget and mood.",
"P3": "Auth service signs the tokens with Ed25519, Core only holds the public key."
"P1": "Steam and PlayStation sync of games, playtime, achievements and trophies, plus time to beat from IGDB.",
"P2": "\"What to play next\": seven rows of suggestions, filtered by a time budget; a mood filter for the random picks.",
"P3": "Auth signs the tokens with Ed25519; Core fetches the public key by key id and only verifies."
}
},
"CLAUDE_BOX": {
@@ -208,55 +208,55 @@
},
"P2": {
"TITLE": "Playground",
"DESCRIPTION": "This project is, as the name suggests, a personal playground that will grow over time. It showcases various private projects I’m working on and documents their progress, key ideas, and milestones.",
"DESCRIPTION": "This site. A portfolio and a place to try algorithms in the browser: pathfinding, sorting, fractals in GLSL, and a cloth and a double pendulum simulation as WebGPU compute shaders. Angular, German and English, built and shipped by my own CI.",
"LINK_EXTERNAL": "andreas-dahm.eu",
"LINK_INTERNAL": "Project details",
"HIGHLIGHTS": {
"P1": "Using modern technologies and CI/CD pipelines (Angular 20+, Spring Boot 4, GitHub).",
"P2": "Showcasing personal projects and improving algorithmic skills over time.",
"P3": "Deepening knowledge in JavaScript/TypeScript, Angular, Spring Boot and related technologies through hands-on practice."
"P1": "Visualizations from Dijkstra to raymarched 3D fractals, all on the client.",
"P2": "Two WebGPU compute simulations (cloth with XPBD, double pendulum) with a CPU or WebGL fallback.",
"P3": "Gitea Actions pipeline: lint, i18n check, tests, Lighthouse, Docker image to my own registry."
}
},
"P1": {
"TITLE": "El Mucho",
"DESCRIPTION": "This is my first published game on Steam. I developed it together with my brother, fulfilling one of my personal goals: creating and releasing my own video game.",
"DESCRIPTION": "My first published game, a turn-based tactical RPG, made with my brother over about three years and released on Steam in 2022. He designed it, I built it.",
"LINK_EXTERNAL": "Steam Store",
"LINK_INTERNAL": "Project details",
"HIGHLIGHTS": {
"P1": "Publishing a game on Steam and integrating the Steam API.",
"P2": "Designing, planning and developing a complete game from scratch.",
"P3": "Implementing complex algorithms, including a custom A* pathfinding system and game AI logic."
"P1": "All the code in Unity (C#), the builds, the Steam setup and the release.",
"P2": "16 missions, achievements, three languages (EN, DE, ES).",
"P3": "A custom AI for the enemy units and my own A* pathfinding on the map grid."
}
},
"P0": {
"TITLE": "Game Jams",
"DESCRIPTION": "This section showcases several of my past game jam creations. Game jams are a great way to explore new game ideas and quickly validate whether a game loop works. The tight time constraints force you to focus, and it’s always exciting to see what can be achieved within such a short timeframe.",
"DESCRIPTION": "Three 48-hour jam games from the BeansJam series, in teams of two or three. I was the programmer and lead each time. All of them are playable on itch.io.",
"LINK_EXTERNAL": "Itch.io Collection",
"LINK_INTERNAL": "Project details",
"HIGHLIGHTS": {
"P1": "Planning a realistic project scope with a team that can be built within 48 hours.",
"P2": "Learning to stay focused and work effectively under strict time constraints.",
"P3": "Experiencing the joy of creating a playable game in a short timeframe and seeing others enjoy it."
"P1": "Peter Blue and the carnival of death (2018), Arkonauts (2020), Sloth Bros (2022).",
"P2": "Friday to Sunday: a playable game in 48 hours, every time.",
"P3": "A cheap way to find out whether a game idea works before committing years to it."
}
},
"DIPLOMA": {
"TITLE": "Diploma thesis - Collision detection and handling of complex garments.",
"DESCRIPTION": "The thesis deals with the detection and handling of collisions between and within individual items of clothing in real time. This is particularly challenging due to the flexibility of fabrics and their varying properties.",
"DESCRIPTION": "Self-intersections inside one garment part, such as a pleated skirt, in the cloth simulation Vidya at assyst. I adapted Intersection Contour Minimization to Vidya's k-DOP hierarchy and extended the response with mass and velocity. FH Bingen, March 2010.",
"LINK_INTERNAL": "Project details",
"HIGHLIGHTS": {
"P1": "Real-time handling of collision detection and response.",
"P2": "Understanding and evaluating scientific papers.",
"P3": "Adaptation and further development of previous research work."
"P1": "Detection on the existing BVH with k-DOPs; one shared hierarchy cut update and traversal time in half.",
"P2": "Compared four published methods and took the global Intersection Contour Minimization variant.",
"P3": "About 20 % faster than the existing self-collision detector plus Layering, planned for productive use."
}
},
"TRIBBLE": {
"TITLE": "Homeserver 'Tribble'",
"DESCRIPTION": "This project is about setting up and maintaining my own homeserver. It runs several Docker containers like Gitea, Jellyfin and more. It's a great learning experience in self-hosting and system administration.",
"DESCRIPTION": "A mini PC at home runs Gitea with its Actions runner and registry, plus Jellyfin, Paperless-ngx and AdGuard Home. A VPS serves the public sites and pulls new images over Tailscale with Watchtower. Running since May 2024, backups to Google Drive.",
"LINK_INTERNAL": "Project details",
"HIGHLIGHTS": {
"P1": "Self-hosting of various services using Docker.",
"P2": "CI/CD pipeline for the personal website using Gitea.",
"P3": "Secure remote access with Tailscale and Traefik."
"P1": "Gitea CI builds and tests this site and To Be Played at home; Watchtower deploys the images on the VPS.",
"P2": "Traefik for TLS and routing, Tailscale between the two machines.",
"P3": "The hard parts were DNS and making the setup repeatable, not the services themselves."
}
}
},
@@ -301,6 +301,7 @@
"PROJECTS": {
"DOWNLOAD": "Download",
"READ_MORE": "Read More",
"DIAGRAM_ALT": "Architecture diagram",
"LINK_TO_PROJECT": "To the project",
"CLOSE": "Close",
"SECTION": {
@@ -308,70 +309,75 @@
"LEARNINGS": "Learnings & Soft Skills"
},
"PLAYGROUND": {
"TITLE": "Playground Portfolio",
"SHORT_DESCRIPTION": "Full-stack portfolio with interactive algorithm visualizations.",
"INTRODUCTION": "This website serves as a living portfolio and testing ground for modern web technologies. The goal is to clearly represent complex algorithms and mathematical concepts (such as WebGPU simulations or Raymarching) directly in the browser.",
"BULLET_1": "Development with Angular 19+ and Material Design.",
"BULLET_2": "Implementation of performant visualizations (WebGPU, Shader, Canvas).",
"BULLET_3": "Automated CI/CD pipelines and containerization with Docker.",
"BULLET_4": "Internationalization (i18n) for global reach.",
"CHALLENGE_1": "Optimizing render performance for complex 3D fractals in real-time.",
"CHALLENGE_2": "Architecting a scalable and maintainable frontend structure for diverse sub-projects.",
"LEARNING_1": "Effective UI/UX design for complex data-driven visualizations.",
"LEARNING_2": "Advanced state management and reactive programming in Angular."
"TITLE": "Playground",
"SHORT_DESCRIPTION": "This site: a portfolio with algorithm visualizations that run in the browser, some of them on the GPU.",
"INTRODUCTION": "This site is where I try things out. I wanted to see how far a plain browser gets with the algorithms I like, so every visualization runs on the client: pathfinding, maze generation, sorting, Conway's Game of Life, fractals in GLSL shaders, and two physics simulations that run as compute shaders on WebGPU. The rest is a normal Angular app with German and English texts, a dark mode, and a CI pipeline that builds and tests it and pushes it as a Docker image to my own registry.",
"BULLET_1": "Pathfinding with Dijkstra and A*, maze generation with Prim and Kruskal, five sorting algorithms with sound, Conway's Game of Life and a four-color map.",
"BULLET_2": "2D fractals (Mandelbrot, Julia, Burning Ship, Newton) in one GLSL shader, and 3D fractals (Mandelbulb, Mandelbox, Julia-Bulb) by raymarching with normals from the distance field.",
"BULLET_3": "A double pendulum and a cloth simulation run as compute shaders on WebGPU. Without WebGPU they fall back to the CPU or WebGL and say so in a snackbar.",
"BULLET_4": "Angular with Material and Tailwind, signals for state, German and English, and a service worker (PWA).",
"BULLET_5": "CI on Gitea Actions: lint, i18n check, tests, production build and a Lighthouse report. On main it builds a Docker image and pushes it to my own registry.",
"CHALLENGE_1": "The cloth solver on the GPU. XPBD constraints that share a particle cannot be solved in parallel without a race. I group the constraints by graph coloring into eight phases, so inside one phase no two constraints touch the same particle and each phase runs fully in parallel.",
"CHALLENGE_2": "Not every browser has WebGPU. Both simulations have a CPU or WebGL path and a snackbar that says the fallback is active, so the page works everywhere and nobody wonders why it is slower.",
"LEARNING_1": "Compute shaders change how you think about an algorithm. The data layout and the order of writes matter more than the math.",
"LEARNING_2": "A small CI with a key-parity check and a Lighthouse run catches the boring mistakes, a missing translation or a slow page, before I see them on the live site."
},
"TRIBBLE": {
"TITLE": "Self-Hosted Infrastructure",
"SHORT_DESCRIPTION": "Home infrastructure with Docker, Traefik, and secure VPN connectivity.",
"INTRODUCTION": "Documentation and construction of a private cloud infrastructure. Focus is on data sovereignty, automation, and security.",
"BULLET_1": "Central management via Docker-Compose and Portainer.",
"BULLET_2": "Automatic SSL management and reverse proxy with Traefik.",
"BULLET_3": "Private version control (Gitea) and media streaming (Jellyfin).",
"BULLET_4": "Network-wide ad-blocking and DNS control via AdGuard Home.",
"CHALLENGE_1": "Configuring secure network layers and firewall rules for remote access.",
"CHALLENGE_2": "Automating backups and recovery strategies for containerized data.",
"LEARNING_1": "Deep understanding of modern network protocols and IT security.",
"LEARNING_2": "Efficient resource management on limited server systems."
"SHORT_DESCRIPTION": "Home server for CI and private services, plus a VPS for the public sites.",
"INTRODUCTION": "Two machines. At home a mini PC called \"Tribble\" runs Gitea with its Actions runner and container registry, plus the private services. A Hostinger VPS serves the public sites, this one included. A git push builds the image at home, the VPS pulls it over Tailscale with Watchtower, and Traefik does TLS and routing. It has been running since May 2024.",
"BULLET_1": "Hardware: a KODLIX GD50 mini PC with an 8-core Intel Core i5, 32 GB RAM and a 512 GB SSD.",
"BULLET_2": "Gitea for git, the Actions runner and the container registry. This site and To Be Played are built and tested there.",
"BULLET_3": "Private services: Jellyfin for media, Paperless-ngx for documents, AdGuard Home for DNS and ad blocking, Portainer to look at the containers, Traefik in front.",
"BULLET_4": "Tailscale links the two machines. The VPS fetches new images from the registry at home over it, and Watchtower swaps the running container.",
"BULLET_5": "Backups go to Google Drive.",
"CHALLENGE_1": "DNS. Names have to resolve to the right address from inside the house, over Tailscale and from the public internet, and AdGuard Home sits in the middle of it. Getting that right took longer than any single service.",
"CHALLENGE_2": "Getting everything to run at the same time, and then making it repeatable. The first setup was a long list of manual steps. I automated it so the server can be rebuilt without me remembering each one.",
"LEARNING_1": "Self-hosting is cheap in money and expensive in time. Most of the time goes into the parts nobody sees: DNS, certificates and backups.",
"LEARNING_2": "A git push that ends in a running container on the VPS, with no manual step in between, is the one part of this setup I would not give up."
},
"EL_MUCHO": {
"TITLE": "El Mucho (Steam Release)",
"SHORT_DESCRIPTION": "Turn-based tactical RPG, published on Steam.",
"INTRODUCTION": "A commercial game project that was independently implemented from the initial idea to the worldwide release on Steam. A tactical RPG that combines classic gameplay elements with modern systems.",
"BULLET_1": "Complete engine development in Unity (C#).",
"BULLET_2": "Integration of Steamworks features (Achievements, Cloud Saves).",
"BULLET_3": "Development of a custom tactical AI and pathfinding logic.",
"BULLET_4": "Management of the entire asset pipeline and sound design.",
"CHALLENGE_1": "Implementing a robust turn-based system with complex dependencies.",
"CHALLENGE_2": "Performance optimization for a smooth experience across various hardware profiles.",
"CHALLENGE_3": "Handling Steam's strict certification requirements.",
"LEARNING_1": "Perseverance and focus over a multi-year development cycle.",
"LEARNING_2": "Marketing and community management for a digital product."
"SHORT_DESCRIPTION": "A turn-based tactical RPG that my brother and I released on Steam.",
"INTRODUCTION": "El Mucho is a turn-based tactical RPG in a fictional world called Liberika, inspired by Langrisser. My brother and I made it over about three years next to a full-time job and released it on Steam: Early Access in July 2021, full release in February 2022. The split was clear. He had the idea and did the assets, the level design, testing and balancing. I did everything technical: the code in Unity, the builds, the Steam setup and the release.",
"BULLET_1": "Unity and C#. One developer for all of the code.",
"BULLET_2": "16 missions, achievements and family sharing on Steam, published as Lobo Corp.",
"BULLET_3": "English, German and Spanish. The Spanish translation was done by a friend who is a native speaker.",
"BULLET_4": "Art and music are partly open source, partly my brother's, partly mine.",
"CHALLENGE_1": "Shipping on Steam as a one-person tech team. Builds, store setup, upload and the Early Access phase all went through me. Early Access started in July 2021, the full release followed in February 2022.",
"CHALLENGE_2": "Three languages. Every text exists in English, German and Spanish; for Spanish I worked with a native-speaking friend.",
"CHALLENGE_3": "The enemy units. I wrote a custom AI for them and my own A* pathfinding on the map grid.",
"LEARNING_1": "Finishing takes longer than building. The stretch from Early Access to the release alone took about seven months.",
"LEARNING_2": "A clear split of roles kept a two-person team working for three years: he owned the game, I owned the code and the release."
},
"GAME_JAMS": {
"TITLE": "Rapid Prototyping & Game Jams",
"SHORT_DESCRIPTION": "Collection of innovative game concepts, created in under 48 hours.",
"INTRODUCTION": "Participation in national competitions (e.g. BeansJam). The focus is on creating functional and fun prototypes under extreme time pressure.",
"BULLET_1": "Focus on 'Core Game Loop' and fast feedback.",
"BULLET_2": "Collaborative development in small, agile teams.",
"BULLET_3": "Effective time management and scope control.",
"BULLET_4": "Publishing and iteration based on community voting.",
"CHALLENGE_1": "Reducing complex ideas to a Minimum Viable Product (MVP) achievable in 48h.",
"CHALLENGE_2": "Rapid bug diagnosis and fixing under massive time pressure.",
"LEARNING_1": "Radical prioritization of features ('Kill your darlings').",
"LEARNING_2": "Effective communication and decision-making under team stress."
"TITLE": "Game Jams",
"SHORT_DESCRIPTION": "Three small games, each made in 48 hours from Friday to Sunday.",
"INTRODUCTION": "A game jam gives you a theme on Friday evening and wants a playable game by Sunday. I have done three so far, all in the BeansJam series, in teams of two or three. I was always the programmer and the lead; the others brought ideas, assets and testing. All three games are on itch.io.",
"BULLET_1": "Peter Blue and the carnival of death (2018, BeansJam): my first jam.",
"BULLET_2": "Arkonauts (2020, Mini Beans Jam 6): space invaders in space.",
"BULLET_3": "Sloth Bros (2022, BeansJam '22): theme \"we have to stay awake\", one of 65 entries.",
"BULLET_4": "Always 48 hours, Friday to Sunday, in a team of two or three.",
"CHALLENGE_1": "Scope. 48 hours is not much, and with sleep it is less. Deciding on Friday what we will not build was the most important decision of each jam.",
"CHALLENGE_2": "Being the only programmer. Everything the team came up with went through my keyboard, so an idea had to be cheap to build or it was dropped.",
"LEARNING_1": "A playable loop on Saturday beats a better idea on Sunday.",
"LEARNING_2": "A jam is where I find out whether a game idea works before spending years on it. El Mucho took three years; a jam game takes a weekend.",
"LINK_COLLECTION": "All games on itch.io"
},
"DIPLOMA": {
"TITLE": "Scientific Diploma Thesis",
"SHORT_DESCRIPTION": "Real-time collision detection for complex, flexible 3D objects.",
"INTRODUCTION": "Research work in the field of computer graphics. Development of an algorithm for physically correct simulation of fabrics and clothing in real-time.",
"BULLET_1": "Mathematical modeling of mass-spring systems.",
"BULLET_2": "Low-level programming with Java.",
"BULLET_3": "Optimization through spatial data structures (AABB Trees, Bounding Spheres).",
"BULLET_4": "Scientific evaluation of simulation precision.",
"CHALLENGE_1": "Handling 'self-collisions' in high-resolution meshes without performance loss.",
"CHALLENGE_2": "Mathematical stabilization of integration methods under high force impacts.",
"LEARNING_1": "Transferring theoretical research into productive, high-performance code.",
"LEARNING_2": "Precise working and documentation according to scientific standards."
"TITLE": "Diploma Thesis: Cloth Self-Collisions",
"SHORT_DESCRIPTION": "Detecting and resolving self-intersections in simulated garments, done at assyst for the cloth simulation Vidya (2010).",
"INTRODUCTION": "Simulated cloth folds into itself. A pleated skirt, for example, intersects with its own fabric, and once two parts of one garment have passed through each other a normal collision response cannot pull them apart again. Collisions between different layers were already handled in Vidya; self-intersections inside one garment part were not. In my diploma thesis at assyst I adapted Intersection Contour Minimization (Volino et al. 2006) to Vidya and extended it so the response uses mass and velocity. Written in Java, finished in March 2010 at FH Bingen, and planned for productive use.",
"BULLET_1": "Detection runs on Vidya's existing bounding volume hierarchy with k-DOPs. For self-tests the traversal skips the inner-node tests it does not need.",
"BULLET_2": "The intersection contour is built from segments. A spatial hash joins them even when floating point errors leave small gaps.",
"BULLET_3": "The response is my extension: it uses mass and velocity, so the result is physically plausible instead of a plain geometric push.",
"BULLET_4": "The parameter h0 is derived from the mean triangle area, so it needs no manual tuning.",
"BULLET_5": "The method shares one BVH with Vidya's existing TriangleSelfCollisionDetector.",
"CHALLENGE_1": "Cost. The test scene had about 16,000 particles at about 430 ms per step on a 2 GHz Core 2 Duo. Sharing one BVH between my detector and the existing one cut the update and traversal time in half and the overhead by about 80 %. On top of the existing detection the method adds about 5 %.",
"CHALLENGE_2": "Choosing the method. I compared Baraff (2003), Bridson (2002) and Volino (2000, 2006) and took the global variant of Intersection Contour Minimization, because it also resolves intersections that already exist.",
"CHALLENGE_3": "Known limits, documented: nearly planar surfaces and a vertex that lies exactly on a triangle plane are not handled.",
"LEARNING_1": "Reading papers properly. The same problem had four published answers, and the useful work was understanding where each one fails.",
"LEARNING_2": "Measure before you claim. The method alone was about 20 % faster than the existing detector plus Layering, and that number only counts because it comes from the same scene on the same machine."
},
"CLAUDE_BOX": {
"TITLE": "Claude-in-a-Box",
@@ -389,17 +395,19 @@
},
"TBP": {
"TITLE": "To Be Played",
"SHORT_DESCRIPTION": "A Steam backlog tracker that helps decide what to play next.",
"INTRODUCTION": "My Steam library has far more games than I will ever finish. To Be Played imports the library, keeps the backlog with status, rating and notes, and suggests what to play next for the time I have. Behind it are three services: an auth service that signs the login tokens, a core service for the library and the statistics, and a React frontend. All of it is self-hosted on the same server as this site.",
"BULLET_1": "Steam sync of owned games, playtime, achievements and wishlist, by hand and every night, plus time to beat from IGDB.",
"BULLET_2": "\"What to play next\": seven rows of suggestions, such as the next part of a series, the same studio, or games that people you follow finished, filtered by a time budget and a mood.",
"SHORT_DESCRIPTION": "A backlog tracker for Steam and PlayStation that helps decide what to play next.",
"INTRODUCTION": "My game library has far more games than I will ever finish. To Be Played syncs Steam and PlayStation, imports lists from GOG, Playnite, Grouvee and HowLongToBeat, keeps the backlog with status, rating and notes, and suggests what to play next for the time I have. Behind it are three services: an auth service that signs the login tokens, a core service for the library and the statistics, and a React frontend. All of it is self-hosted on the same server as this site.",
"BULLET_1": "Steam sync of owned games, playtime, achievements and wishlist, PlayStation sync of titles, playtime and trophies, by hand and every night, plus time to beat from IGDB.",
"BULLET_2": "\"What to play next\": seven rows of three suggestions, such as the next part of a series, the same studio, or games that people you follow played or finished. A time budget filters every row; the mood filter only shapes the random picks.",
"BULLET_3": "Public profiles, following other players with an inbox of their progress, and Pile Points for every game played off the pile.",
"BULLET_4": "German and English, light and dark mode, and it works on the phone. Auth, Core and Frontend each ship as their own Docker image.",
"CHALLENGE_1": "Login across two services. Auth signs the tokens with an Ed25519 key, and Core only holds the public key, so Core can check a login but never create one. The tokens live in HttpOnly cookies, so no script on the page can read them.",
"CHALLENGE_2": "Steam's limits. Every Steam call is rate limited and retried, and a sync first claims its run in the database, so two syncs of the same user never run at the same time.",
"BULLET_5": "Over 3,400 tests across the three services, ArchUnit rules that keep the layers apart, and Testcontainers for the real database. CI runs the full build and tags every image by commit.",
"CHALLENGE_1": "Login across two services. Auth signs the tokens with an Ed25519 key, and Core only holds the public key, so Core can check a login but never create one. Core fetches that key from Auth at runtime, looked up by key id, and the tokens live in HttpOnly cookies, so no script on the page can read them.",
"CHALLENGE_2": "Steam's limits. Every Steam call is rate limited to two per second and retried only where a retry is safe (owned games, schema, achievements). A sync first claims its run in the database, so two syncs of the same user never run at the same time, and the nightly sync only touches users seen in the last 14 days.",
"CHALLENGE_3": "Suggesting a game is harder than it sounds. Each row of suggestions needs its own rules, and the lists behind every number must use the same rules as the number, so a game counts the same way everywhere.",
"LEARNING_1": "Splitting an app into services only pays off with a clear contract. The token claims are pinned in a contract file that both services test against, and a script checks that both copies match.",
"LEARNING_2": "A hobby project still needs the boring parts: backups, migrations and a fixed deploy order. They decided more about how stable it runs than any feature did."
"CHALLENGE_4": "Login abuse. Too many failed logins get a 429 with Retry-After. An unknown email still pays for a full BCrypt check, so the timing gives nothing away. A header guard blocks CSRF, CORS allows exact origins only, and the PlayStation token is encrypted at rest.",
"LEARNING_1": "Splitting an app into services only pays off with a clear contract. The token claims are pinned in a contract file that both services test against, and a script I run by hand checks that both copies match.",
"LEARNING_2": "A hobby project still needs the boring parts: daily database dumps kept for seven days, four weeks and six months, migrations, and a written deploy order. They decided more about how stable it runs than any feature did."
}
},
"IMPRINT": {
@@ -0,0 +1,82 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630" role="img" aria-labelledby="title desc">
<title id="title">To Be Played</title>
<desc id="desc">Architecture of To Be Played: a React frontend talks to an Auth service that signs tokens and a Core service that verifies them. Both use PostgreSQL, a backup container dumps both databases daily. Core also calls the Steam API, PlayStation and IGDB. Core fetches the Auth public key (JWKS) at runtime.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#1e2530"/>
<stop offset="100%" stop-color="#15181d"/>
</linearGradient>
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#26c6da"/>
<stop offset="100%" stop-color="#ffa726"/>
</linearGradient>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#4a5563"/>
</marker>
<marker id="arrowCyan" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#26c6da"/>
</marker>
</defs>
<style>
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; }
.sans { font-family: Roboto, Arial, sans-serif; }
.lbl { fill: #8b97a6; font-size: 17px; letter-spacing: .04em; }
.chip { fill: #cdd6e0; font-size: 19px; }
.cap { fill: #6c7887; font-size: 15px; letter-spacing: .18em; }
.name { fill: #cdd6e0; font-size: 22px; font-weight: 600; }
.sub { fill: #8b97a6; font-size: 16px; }
.arr { fill: #8b97a6; font-size: 15px; }
</style>
<rect width="1200" height="630" fill="url(#bg)"/>
<rect x="0" y="0" width="1200" height="4" fill="url(#accent)"/>
<text x="64" y="72" class="mono" fill="#26c6da" font-size="26" font-weight="600">to-be-played</text>
<text x="64" y="102" class="sans lbl">three services, one login</text>
<rect x="48" y="240" width="196" height="110" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="68" y="274" class="sans name">React Frontend</text>
<text x="68" y="300" class="sans sub">browser</text>
<rect x="350" y="150" width="310" height="120" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="370" y="184" class="sans name">Auth service</text>
<text x="370" y="210" class="sans sub">signs tokens, Ed25519 private key</text>
<text x="370" y="232" class="sans sub">HttpOnly cookies</text>
<rect x="350" y="320" width="310" height="220" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="370" y="354" class="sans name">Core service</text>
<text x="370" y="380" class="sans sub">library, stats, suggestions</text>
<text x="370" y="402" class="sans sub">verifies tokens, public key only</text>
<rect x="740" y="150" width="180" height="230" rx="14" fill="#1a222c" stroke="#2f3a47"/>
<text x="760" y="178" class="sans cap" text-anchor="start">POSTGRESQL</text>
<rect x="760" y="192" width="140" height="56" rx="10" fill="#243040" stroke="#33475c"/>
<text x="830" y="227" class="mono chip" text-anchor="middle">auth db</text>
<rect x="760" y="312" width="140" height="56" rx="10" fill="#243040" stroke="#33475c"/>
<text x="830" y="347" class="mono chip" text-anchor="middle">core db</text>
<text x="1000" y="357" class="sans cap" text-anchor="start">EXTERNAL</text>
<rect x="1000" y="380" width="152" height="50" rx="10" fill="#212a35" stroke="#2f3a47" stroke-dasharray="4 4"/>
<text x="1076" y="412" class="sans chip" text-anchor="middle">Steam API</text>
<rect x="1000" y="442" width="152" height="50" rx="10" fill="#212a35" stroke="#2f3a47" stroke-dasharray="4 4"/>
<text x="1076" y="474" class="sans chip" text-anchor="middle">PlayStation</text>
<rect x="1000" y="504" width="152" height="50" rx="10" fill="#212a35" stroke="#2f3a47" stroke-dasharray="4 4"/>
<text x="1076" y="536" class="sans chip" text-anchor="middle">IGDB</text>
<path d="M 252 258 H 342" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<text x="297" y="248" class="sans arr" text-anchor="middle">login</text>
<path d="M 252 340 H 342" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<text x="297" y="330" class="sans arr" text-anchor="middle">API, cookie</text>
<path d="M 668 220 H 752" stroke="#26c6da" stroke-width="2" fill="none" marker-end="url(#arrowCyan)"/>
<path d="M 668 340 H 752" stroke="#26c6da" stroke-width="2" fill="none" marker-end="url(#arrowCyan)"/>
<path d="M 600 312 V 278" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<text x="588" y="300" class="sans arr" text-anchor="end">public key (JWKS)</text>
<path d="M 668 405 H 992" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<text x="830" y="395" class="sans arr" text-anchor="middle">sync</text>
<path d="M 668 467 H 992" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<text x="830" y="457" class="sans arr" text-anchor="middle">sync</text>
<path d="M 668 529 H 992" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<text x="830" y="519" class="sans arr" text-anchor="middle">time to beat</text>
<rect x="1000" y="228" width="152" height="76" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="1020" y="258" class="sans name">db backup</text>
<text x="1020" y="284" class="sans sub">daily dumps</text>
<path d="M 928 266 H 992" stroke="#26c6da" stroke-width="2" fill="none" marker-end="url(#arrowCyan)"/>
<line x1="64" y1="566" x2="1136" y2="566" stroke="#2a333e"/>
<text x="64" y="598" class="mono" fill="#5b6673" font-size="15">react · spring boot · postgresql · docker</text>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

@@ -0,0 +1,84 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630" role="img" aria-labelledby="title desc">
<title id="title">Self-hosted infrastructure</title>
<desc id="desc">Architecture of the self-hosted infrastructure: a push to Gitea on the home server builds an image at home, Watchtower on the Hostinger VPS pulls it over Tailscale, and Traefik routes the public sites.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#1e2530"/>
<stop offset="100%" stop-color="#15181d"/>
</linearGradient>
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#26c6da"/>
<stop offset="100%" stop-color="#ffa726"/>
</linearGradient>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#4a5563"/>
</marker>
<marker id="arrowCyan" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#26c6da"/>
</marker>
</defs>
<style>
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; }
.sans { font-family: Roboto, Arial, sans-serif; }
.lbl { fill: #8b97a6; font-size: 17px; letter-spacing: .04em; }
.chip { fill: #cdd6e0; font-size: 19px; }
.cap { fill: #6c7887; font-size: 15px; letter-spacing: .18em; }
.name { fill: #cdd6e0; font-size: 22px; font-weight: 600; }
.sub { fill: #8b97a6; font-size: 16px; }
.arr { fill: #8b97a6; font-size: 15px; }
</style>
<rect width="1200" height="630" fill="url(#bg)"/>
<rect x="0" y="0" width="1200" height="4" fill="url(#accent)"/>
<text x="64" y="72" class="mono" fill="#26c6da" font-size="26" font-weight="600">Self-hosted infrastructure</text>
<text x="64" y="102" class="sans lbl">build at home, serve from the VPS</text>
<rect x="130" y="140" width="460" height="390" rx="18" fill="#1a222c" stroke="#26c6da" stroke-width="2"/>
<text x="154" y="168" class="sans cap" text-anchor="start">HOME SERVER "TRIBBLE"</text>
<rect x="154" y="186" width="412" height="130" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="174" y="220" class="sans name">Gitea</text>
<text x="174" y="246" class="sans sub">git, Actions runner, container registry</text>
<rect x="154" y="346" width="128" height="58" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="218" y="382" class="sans chip" style="font-size:16px" text-anchor="middle">Traefik</text>
<rect x="296" y="346" width="128" height="58" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="360" y="382" class="sans chip" style="font-size:16px" text-anchor="middle">Portainer</text>
<rect x="438" y="346" width="128" height="58" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="502" y="382" class="sans chip" style="font-size:16px" text-anchor="middle">Jellyfin</text>
<rect x="154" y="418" width="128" height="58" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="218" y="454" class="sans chip" style="font-size:16px" text-anchor="middle">AdGuard Home</text>
<rect x="296" y="418" width="128" height="58" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="360" y="454" class="sans chip" style="font-size:16px" text-anchor="middle">Paperless-ngx</text>
<rect x="438" y="418" width="128" height="58" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="502" y="454" class="sans chip" style="font-size:16px" text-anchor="middle">Tailscale</text>
<rect x="700" y="140" width="452" height="390" rx="18" fill="#1a222c" stroke="#ffa726" stroke-width="2"/>
<text x="724" y="168" class="sans cap" text-anchor="start">HOSTINGER VPS</text>
<rect x="724" y="206" width="404" height="90" rx="10" fill="#243040" stroke="#33475c"/>
<text x="744" y="240" class="sans name">Watchtower</text>
<text x="744" y="266" class="sans sub">pulls new images automatically</text>
<path d="M 736 296 V 484" stroke="#4a5563" stroke-width="2"/>
<text x="750" y="322" class="sans arr" text-anchor="start">deploy</text>
<rect x="752" y="330" width="216" height="52" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="770" y="362" class="sans chip" style="font-size:17px" text-anchor="start">Playground (this site)</text>
<path d="M 736 356 H 750" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<path d="M 1000 356 H 976" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<rect x="752" y="394" width="216" height="52" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="770" y="426" class="sans chip" style="font-size:17px" text-anchor="start">Pepper Pepperoni</text>
<path d="M 736 420 H 750" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<path d="M 1000 420 H 976" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<rect x="752" y="458" width="216" height="52" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="770" y="490" class="sans chip" style="font-size:17px" text-anchor="start">To Be Played</text>
<path d="M 736 484 H 750" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<path d="M 1000 484 H 976" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<rect x="1000" y="330" width="128" height="180" rx="10" fill="#2a2118" stroke="#5c4326"/>
<text x="1016" y="368" class="sans name" text-anchor="start">Traefik</text>
<text x="1016" y="394" class="sans sub" text-anchor="start">TLS, routing</text>
<path d="M 40 251 H 150" stroke="#26c6da" stroke-width="2" fill="none" marker-end="url(#arrowCyan)"/>
<text x="44" y="240" class="mono arr" text-anchor="start">git push</text>
<path d="M 574 251 H 716" stroke="#26c6da" stroke-width="2" fill="none" marker-end="url(#arrowCyan)"/>
<text x="645" y="214" class="sans arr" text-anchor="middle">new image</text>
<text x="645" y="234" class="sans arr" text-anchor="middle">via Tailscale</text>
<line x1="64" y1="566" x2="1136" y2="566" stroke="#2a333e"/>
<text x="64" y="598" class="mono" fill="#5b6673" font-size="15">gitea · docker · traefik · tailscale · watchtower</text>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB