A Number Guessing Game
Sometimes the best way to learn a framework is to build something simple. In this chapter we build a number guessing game as a Tauri v2 desktop application using Vite and vanilla TypeScript. The app picks a random number between 1 and 100, the player guesses, and the app responds with “higher,” “lower,” or “win.” It is a tiny project, but it teaches us several important things: how Tauri wraps a web frontend into a native window, how vanilla TypeScript handles DOM events without a framework like React or Vue, and — perhaps most importantly — that not every Tauri app needs custom Rust backend commands.
Dear reader, the example in this chapter is very simple. Once you understand how we use TypeScript application code to interact with DOM elements and events, feel free to move on to the next two chapters that have real applications that I wrote for my own use.
Many Tauri tutorials focus on the invoke bridge between TypeScript and Rust. That bridge is powerful when you need filesystem access, system-level networking, or heavy computation. But for a game whose entire state fits in a single integer, all of the logic lives comfortably on the frontend. The Rust side is pure scaffolding: Tauri’s default boilerplate with no custom commands called by our game code. This is a useful mental model. Think of Tauri as a spectrum: on one end you have apps that are essentially web pages in a native window, and on the other end you have apps where the frontend is a thin shell over a complex Rust backend. Our number guessing game sits firmly at the simple end, and that is perfectly fine.
The examples for this chapter are in the directory number-guess-app.
Project Structure
The project follows the standard layout that npm create tauri-app generates. The frontend lives in the root and src/ directories, while the Tauri/Rust backend lives under src-tauri/:
1 number-guess-app/
2 ├── index.html // Entry point HTML, loaded by Tauri's webview
3 ├── package.json // npm scripts and dependencies
4 ├── vite.config.ts // Vite dev server config (fixed port for Tauri)
5 ├── tsconfig.json // TypeScript compiler options
6 ├── src/
7 │ ├── main.ts // All game logic: state, events, DOM updates
8 │ └── styles.css // Styling (Tauri default template + game tweaks)
9 └── src-tauri/
10 ├── tauri.conf.json // Tauri window size, title, build commands
11 └── src/
12 ├── main.rs // Rust binary entry point (generated, untouched)
13 └── lib.rs // Rust library with default greet command (unused)
Notice that src/main.ts is the only file we wrote meaningful code in. Everything else is either generated by the Tauri scaffolding tool or is configuration. This is one of the appeals of Tauri for simple apps: the amount of boilerplate you need to touch is very small.
The HTML Shell
The index.html file defines the structure of our game UI. It is intentionally minimal:
1 <!doctype html>
2 <html lang="en">
3 <head>
4 <meta charset="UTF-8" />
5 <link rel="stylesheet" href="/src/styles.css" />
6 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7 <title>Guess the Number</title>
8 <script type="module" src="/src/main.ts" defer></script>
9 </head>
10 <body class="user_selected_bg">
11 <main class="container">
12 <h1>Guess a Number</h1>
13 <p>I'm thinking of a number between 1 and 100.</p>
14 <form class="row" id="guess-form">
15 <input id="guess-input" type="number" placeholder="Enter your guess..."
16 min="1" max="100" />
17 <button type="submit">Guess</button>
18 </form>
19 <p id="guess-msg"></p>
20 <div class="row" style="margin-top: 20px;">
21 <button id="new-game-btn">New Game</button>
22 </div>
23 </main>
24 </body>
25 </html>
A few things to note here. The <script> tag uses type="module" so that Vite can process the TypeScript file directly during development — no separate compile step is needed before the browser sees it. The <input> element uses type="number" with min="1" and max="100", which gives us free browser-level validation: on most platforms, the input field renders with spinner arrows and rejects non-numeric input. We wrap the input and button in a <form> element so that pressing Enter triggers a submit event, which is more natural than requiring the user to click the button. The <p id="guess-msg"> element is our feedback area — it starts empty and gets updated by JavaScript after each guess.
The id attributes on key elements (guess-form, guess-input, guess-msg, new-game-btn) serve as the connection points between our HTML and TypeScript. We will query for these IDs in main.ts to attach event listeners and update content.
Game Logic: src/main.ts
All of the game’s logic lives in a single TypeScript file. Let’s look at the complete listing and then walk through each piece:
1 let targetNumber: number;
2 let guessInputEl: HTMLInputElement | null;
3 let guessMsgEl: HTMLElement | null;
4
5 function initGame() {
6 targetNumber = Math.floor(Math.random() * 100) + 1;
7 if (guessMsgEl) {
8 guessMsgEl.textContent = "I'm thinking of a number between 1 and 100.";
9 }
10 }
11
12 async function handleGuess(e: Event) {
13 e.preventDefault();
14 if (!guessInputEl || !guessMsgEl) return;
15
16 const guess = parseInt(guessInputEl.value);
17 if (isNaN(guess)) {
18 guessMsgEl.textContent = "Please enter a valid number.";
19 return;
20 }
21
22 if (guess < targetNumber) {
23 guessMsgEl.textContent = "higher";
24 } else if (guess > targetNumber) {
25 guessMsgEl.textContent = "lower";
26 } else {
27 guessMsgEl.textContent = "win";
28 }
29
30 guessInputEl.value = "";
31 }
32
33 window.addEventListener("DOMContentLoaded", () => {
34 guessInputEl = document.querySelector("#guess-input");
35 guessMsgEl = document.querySelector("#guess-msg");
36 const guessForm = document.querySelector("#guess-form");
37 const newGameBtn = document.querySelector("#new-game-btn");
38
39 if (guessForm) {
40 guessForm.addEventListener("submit", handleGuess);
41 }
42
43 if (newGameBtn) {
44 newGameBtn.addEventListener("click", () => {
45 initGame();
46 });
47 }
48
49 initGame();
50 });
Module-Level State
1 let targetNumber: number;
2 let guessInputEl: HTMLInputElement | null;
3 let guessMsgEl: HTMLElement | null;
We declare three module-level variables. targetNumber holds the secret number the player is trying to guess. guessInputEl and guessMsgEl are references to DOM elements that we query once at startup and reuse throughout the game. Typing guessInputEl as HTMLInputElement | null is important: document.querySelector can return null if the element isn’t found, and TypeScript will force us to handle that case. This is one of the advantages of TypeScript over plain JavaScript — the type system catches potential null-reference bugs at compile time rather than at runtime.
Initializing a New Game
1 function initGame() {
2 targetNumber = Math.floor(Math.random() * 100) + 1;
3 if (guessMsgEl) {
4 guessMsgEl.textContent = "I'm thinking of a number between 1 and 100.";
5 }
6 }
The initGame function picks a new random integer in the range [1, 100]. The expression Math.floor(Math.random() * 100) + 1 is a standard pattern: Math.random() returns a float in [0, 1), multiplying by 100 gives [0, 100), Math.floor truncates to an integer in [0, 99], and adding 1 shifts to [1, 100]. We also reset the feedback message so the player knows a new game has started. This function is called both at startup and whenever the player clicks the “New Game” button.
Handling a Guess
1 async function handleGuess(e: Event) {
2 e.preventDefault();
3 if (!guessInputEl || !guessMsgEl) return;
4
5 const guess = parseInt(guessInputEl.value);
6 if (isNaN(guess)) {
7 guessMsgEl.textContent = "Please enter a valid number.";
8 return;
9 }
10
11 if (guess < targetNumber) {
12 guessMsgEl.textContent = "higher";
13 } else if (guess > targetNumber) {
14 guessMsgEl.textContent = "lower";
15 } else {
16 guessMsgEl.textContent = "win";
17 }
18
19 guessInputEl.value = "";
20 }
The handleGuess function is the heart of the game. Let’s trace through the key decisions:
e.preventDefault() — Because our input is wrapped in a <form>, pressing Enter or clicking “Guess” triggers the browser’s default form submission behavior, which would reload the page. We prevent that since we want to handle the guess entirely in JavaScript.
Null guards — The if (!guessInputEl || !guessMsgEl) return check satisfies TypeScript’s strictness. If either DOM element wasn’t found, we bail out safely rather than crashing on a null dereference.
Input validation — We parse the input with parseInt and check for NaN. Even though the HTML <input type="number"> provides some protection, a determined user could still submit an empty field or other edge-case values. Defensive parsing is always a good habit.
The comparison logic — This is a classic three-way branch. If the guess is too low, we tell the player to go “higher.” If too high, we say “lower.” If equal, we say “win.” Notice that the hints follow a binary search strategy: each hint eliminates roughly half of the remaining possibilities. A player who follows the hints optimally — always guessing the midpoint of the remaining range — can find any number in at most 7 guesses (since ⌈log₂(100)⌉ = 7). This is a fun observation that connects a simple game to a fundamental computer science concept.
Clearing the input — After processing the guess, we reset guessInputEl.value = "" so the player can immediately type their next guess without manually clearing the field. This is a small UX detail that makes the game feel responsive.
Note that handleGuess is declared as async even though it contains no await calls. This is a leftover from the Tauri template, which expects the form handler to potentially call async Rust commands via invoke. Since our game doesn’t use any Rust commands, the async keyword is harmless but unnecessary. I’ve left it in to show you what the template generates — in your own projects, feel free to remove async from handlers that don’t need it.
Wiring Up the DOM
1 window.addEventListener("DOMContentLoaded", () => {
2 guessInputEl = document.querySelector("#guess-input");
3 guessMsgEl = document.querySelector("#guess-msg");
4 const guessForm = document.querySelector("#guess-form");
5 const newGameBtn = document.querySelector("#new-game-btn");
6
7 if (guessForm) {
8 guessForm.addEventListener("submit", handleGuess);
9 }
10
11 if (newGameBtn) {
12 newGameBtn.addEventListener("click", () => {
13 initGame();
14 });
15 }
16
17 initGame();
18 });
We wait for the DOMContentLoaded event before querying elements, this ensures the HTML has been fully parsed. We attach two event listeners: the form’s submit event triggers handleGuess, and the “New Game” button’s click event calls initGame to reset the game. Finally, we call initGame() to start the first game immediately.
This pattern — query elements once, attach listeners, initialize state — is the standard approach for vanilla TypeScript DOM applications. If you’ve worked with React or Vue, this may feel verbose. But the directness has its own appeal: there is no virtual DOM, no reactivity system, no build-time compilation of templates. What you write is what runs.
The Tauri Configuration
The src-tauri/tauri.conf.json file tells Tauri how to build and run the app:
1 {
2 "$schema": "https://schema.tauri.app/config/2",
3 "productName": "number-guess-app",
4 "version": "0.1.0",
5 "identifier": "com.markwatson.number-guess-app",
6 "build": {
7 "beforeDevCommand": "npm run dev",
8 "devUrl": "http://localhost:1420",
9 "beforeBuildCommand": "npm run build",
10 "frontendDist": "../dist"
11 },
12 "app": {
13 "withGlobalTauri": true,
14 "windows": [
15 {
16 "title": "number-guess-app",
17 "width": 800,
18 "height": 600
19 }
20 ],
21 "security": {
22 "csp": null
23 }
24 }
25 }
The build section is where the frontend and backend connect. When you run npm run tauri dev, Tauri first executes beforeDevCommand (npm run dev) to start the Vite dev server on port 1420, then opens a native window pointing at devUrl. During production builds, beforeBuildCommand runs npm run build to produce the optimized dist/ folder, which gets bundled into the final binary.
The app.windows array configures the native window: 800×600 pixels with the title “number-guess-app.” You can adjust these values, add multiple windows, or configure window decorations here.
The Rust Backend (Unused but Present)
The src-tauri/src/lib.rs file contains the standard Tauri boilerplate:
1 #[tauri::command]
2 fn greet(name: &str) -> String {
3 format!("Hello, {}! You've been greeted from Rust!", name)
4 }
5
6 #[cfg_attr(mobile, tauri::mobile_entry_point)]
7 pub fn run() {
8 tauri::Builder::default()
9 .plugin(tauri_plugin_opener::init())
10 .invoke_handler(tauri::generate_handler![greet])
11 .run(tauri::generate_context!())
12 .expect("error while running tauri application");
13 }
The greet command is the one generated by create-tauri-app. Our game code never calls it. The greet command is still registered with .invoke_handler(tauri::generate_handler![greet]), which is fine — unused commands don’t add meaningful overhead.
This is the key architectural takeaway of this chapter: Tauri gives you a Rust backend for free, but you only pay for it when you use it. The game works entirely in the webview. Tauri’s role here is simply to wrap that webview into a native macOS, Windows, or Linux application with proper window management, taskbar integration, and a small binary size compared to Electron.
The Vite Configuration
The vite.config.ts file configures the Vite development server to work smoothly with Tauri:
1 import { defineConfig } from "vite";
2
3 // @ts-expect-error process is a nodejs global
4 const host = process.env.TAURI_DEV_HOST;
5
6 export default defineConfig(async () => ({
7 clearScreen: false,
8 server: {
9 port: 1420,
10 strictPort: true,
11 host: host || false,
12 hmr: host
13 ? {
14 protocol: "ws",
15 host,
16 port: 1421,
17 }
18 : undefined,
19 watch: {
20 ignored: ["**/src-tauri/**"],
21 },
22 },
23 }));
Three settings are important here. First, clearScreen: false prevents Vite from clearing the terminal, so you can still see Rust compiler output from the Tauri process. Second, port: 1420 with strictPort: true ensures the dev server always runs on port 1420 — this must match the devUrl in tauri.conf.json. If the port is taken, the server will fail rather than silently choosing another port (which would break the Tauri connection). Third, the watch.ignored array tells Vite not to watch src-tauri/ for changes, since Rust recompilation is handled by Tauri’s own file watcher.
Running the Example
Install the npm dependencies and launch the Tauri development build:
1 cd number-guess-app
2 npm install
3 npm run tauri dev
The first run takes a minute or two because Cargo needs to compile Tauri’s Rust dependencies. Subsequent runs are much faster. Once the build finishes, a native window appears with the game UI. You should see:
1 Guess a Number
2 I'm thinking of a number between 1 and 100.
3
4 [ Enter your guess... ] [Guess]
5
6 [New Game]
Try playing a game using the binary search strategy:
- Guess 50. The app responds “higher” or “lower.”
- If “higher,” guess 75. If “lower,” guess 25.
- Continue halving the range. You will always find the number in 7 or fewer guesses.
When the app displays “win,” click “New Game” to reset with a fresh random number. The input field clears after each guess, so you can type your next guess immediately.
To build a distributable binary:
1 npm run tauri build
This produces a native application bundle in src-tauri/target/release/bundle/. On macOS you get a .dmg installer, on Windows an .msi or .exe, and on Linux .deb and .AppImage files.
A Number Guessing Game Wrap-up
This small project demonstrates several ideas worth remembering:
- Not all Tauri apps need Rust commands. If your logic is entirely client-side — game state, form validation, UI updates — you can let the Rust backend sit idle. Tauri still provides a native window, small binary size, and cross-platform builds.
- Vanilla TypeScript is viable for simple UIs. Frameworks like React add value when you have complex state management, component hierarchies, and data flow. For a single-page game with three DOM elements,
querySelectorandaddEventListenerare all you need. - The binary search connection. The “higher/lower” hints in our game are exactly the feedback you’d get from a binary search. An optimal player eliminates half the search space with each guess, reaching the answer in ⌈log₂(100)⌉ = 7 guesses. This makes the game a nice interactive demonstration of logarithmic search.
- Tauri’s Vite integration is seamless. Hot module replacement works during development: edit
main.tsorstyles.cssand the changes appear instantly in the native window without restarting the app. The fixed-port configuration invite.config.tsensures the frontend and backend stay connected.
In the next chapter we will build an app that does use the Tauri invoke bridge to call Rust backend commands, showing the other end of the Tauri complexity spectrum.