Concept
The idea for this project actually came from a quiet weekend when I watched the Chinese movie Letters to Grandma(《给阿嬷的情书》). The story really hit home for me. It’s about a time before smartphones and social media, when people had to write real letters across oceans, waiting endlessly just to share their feelings with family and loved ones. That deep sense of yearning and patience resonated with me so much that I was like, “I really, really want to build something that brings that letter-writing feeling back.”
In the film, flowers and ocean waves are huge symbols of distance and memory, and I was so set on bringing those exact visuals into my project, along with some traditional Chinese aesthetic touches. I spent a ton of time tweaking the visuals—making the falling petals and moving waves look just right—and honestly, seeing it all come together gave me such a huge sense of achievement!
At first, I just had on-screen typing, but then I realized just typing wasn’t quite fun or meaningful enough. I really wanted it to feel real. After getting some great advice from my professor, I added a feature where you can actually export what you write into a real Word document (.docx). That way, it’s not just a digital demo—you get to save your words as a real letter, just like people used to do back in the day.
![]()
How this was made
The Home Page, Layered Aesthetics & Swaying Physics
For the home page, Instead of relying on a flat, static layout, I spent a lot of time crafting a rich 3D depth by splicing three separate flower images (flower.png, flower2.png, etc.) together so that the hanging stationery looks like it’s literally growing out from deep inside the blossoms. However, the most challenging technical hurdle here was simulating realistic pendulum motion for the hanging ropes and letter cards. I solved this by using trigonometric sine waves (sin()) tied to frameCount,(learn from YouTube video)generating independent speeds, phase offsets, and curved paths for each individual rope:
function createRopes() {
let lengths = [250, 340, 270, 395, 305, 370, 260, 420, 285, 355];
let positions = [-115, -86, -57, -29, -10, 10, 29, 57, 86, 115];
ropes = [];
for (let i = 0; i < 10; i++) {
ropes.push({
offsetX: positions[i], length: lengths[i],
swingSpeed: random(0.008, 0.02), phase: random(TWO_PI),
swingAmount: random(8, 14), rotationAmount: random(0.04, 0.08)
});
}
}
function drawRopes() {
let ropeStartY = getRopeStartY();
for (let rope of ropes) {
let swing = sin(frameCount * rope.swingSpeed + rope.phase);
let sway = swing * rope.swingAmount;
beginShape();
for (let j = 0; j <= 24; j++) {
let t = j / 24;
let y = ropeStartY + rope.length * t;
let curve = (t + sin(t * PI) * 0.6) * sway;
vertex(baseX + curve, y);
}
endShape();
}
}
Interactive Writing, Text Scrolling
When users click a letter to transition into the writing view, I built a virtual keyboard to increase interactivity .Every time a key is pressed.it triggers a audio feedback using singleKey.wav and lights up a temporary press highlight (flashKey) to mimic a real keyboard:
function drawKeyboard() {
let rows = ["QWERTYUIOP", "ASDFGHJKL", "ZXCVBNM"];
let keyW = 32, keyH = 34, gap = 4, startY = 475;
for (let r = 0; r < rows.length; r++) {
let letters = rows[r];
let totalWidth = letters.length * keyW + (letters.length - 1) * gap;
let startX = (width - totalWidth) / 2;
for (let i = 0; i < letters.length; i++) {
let letterKey = letters[i];
let x = startX + i * (keyW + gap), y = startY + r * (keyH + gap);
let isPressed = flashKey === letterKey && frameCount < flashUntilFrame;
if (isPressed) { fill(0); stroke(0); rect(x, y, keyW, keyH, 4); fill(255); }
else { fill(255, 160); stroke(0, 120); rect(x, y, keyW, keyH, 4); fill(0, 200); }
noStroke(); text(letterKey, x + keyW / 2, y + keyH / 2);
}
}
}
function drawTypedText() {
push();
textFont("cursive"); textSize(20); fill(50, 50, 50);
let lines = wrapText(typedText, maxCharsPerLine).slice(-9);
for (let i = 0; i < lines.length; i++) {
text(lines[i], textStartX, textStartY + i * textLineHeight);
}
if (typingActive && frameCount % 60 < 30 && lines.length > 0) {
let lastLine = lines[lines.length - 1];
let cursorX = textStartX + textWidth(lastLine);
let cursorY = textStartY + (lines.length - 1) * textLineHeight;
line(cursorX + 2, cursorY + 2, cursorX + 2, cursorY + 20);
}
pop();
}
Ocean Waves & Practical Word Export
To transition out of the writing screen, I made a design use multi-layered ocean waves (drawWave()) . By stacking multiple wave layers with varying frequencies, semi-transparent water stream lines, and glowing foam highlights, the ocean rises to cover the canvas:
function drawWave() {
push(); noStroke(); wavePhase += waveSpeed;
let baseY = height - calmWaveHeight, riseHeight = 0;
if (waveSending) {
let elapsed = frameCount - waveSendStartFrame;
let t = constrain(elapsed / waveSendDuration, 0, 1);
let maxRise = height * 0.52;
riseHeight = t < 0.46 ? maxRise * easeWave(t / 0.46) : t < 0.64 ? maxRise : maxRise * (1 - easeWave((t - 0.64) / 0.36));
}
let backY = baseY - riseHeight;
drawWaveLayer(backY + 32, 27, 0.012, 0, waveColors[0], 0.7);
drawWaveLayer(backY, 8, 0.040, 6.0, waveColors[4], 1.0);
drawInternalStreams(backY);
pop();
}
Once the wave completely clears the screen, it triggers the document saving process. To be honest, I didn’t write the Word export logic entirely from scratch—I did some research online to figure out how web apps export files, discovered the docxlibrary, and then pieced together and adapted the code to fit my project’s data flow
async function downloadWordDocument(content) {
const { Document, Paragraph, TextRun, Packer } = docx;
const paragraphs = content.split(/\r?\n/).map(line => new Paragraph({
children: [new TextRun({ text: line || " ", font: "Georgia", size: 24 })],
spacing: { after: 120 }
}));
const wordDocument = new Document({ sections: [{ children: paragraphs }] });
const blob = await Packer.toBlob(wordDocument);
// Trigger browser download...
}
Improvement and problems
Reference
YouTube videos:
https://youtu.be/N633bLi_YCw?si=SZS-L-PLuXJI5_Ag https://youtu.be/o9sgjuh-CBM?si=0U7rIlb-7wCXG49J https://youtu.be/qRnUBiTJ66Y?si=zR0N2T6e4cUjKULZ