30 Days of Code — Day 6
Simple TTRPG Dice Roller
Today I want to start the work on the UI for the application. I’m expecting it will be a fairly simple UI to start with, but I think we should still take a moment to plan it out. Let’s hop back into Figma to take a look.
For now, we’re really only concerned with having the user enter a string-based roll in the dice notation we’ve defined. That will only require a single text input, a submit button, and some space to display the results. We’ll add more later, but for now I want to get this core functionality knocked out.
To make life a little bit easier because I’m by no means a designer, I’m going to be using the Ionic framework which I’ve written about previously.
Let’s start with a bit of organization. I like to create a folder for my components:
Inside of that I’m going to create a DiceRollForm.jsx and a Results.jsx
DiceRollForm
The DiceRollForm for now will consist of a single input and a button. The input will be required, and we’ll add some additional client-side validation to that in a moment. We’re going to bind the value to the input state, and bind the onIonInput (similar to onChange) to setInput state.
import { useState} from "react";
import "./componentStyles.css";
import { IonButton, IonInput, IonItem } from "@ionic/react";
export default function DiceRollForm({ setResults, setError }) {
const [input, setInput] = useState("");
const [fieldError, setFieldError] = useState(null);
return (
<form onSubmit={handleSubmit} className="diceInputForm">
<div className="formGrid">
<IonItem>
<IonInput
required
label="Enter a dice roll:"
value={input}
id="diceInput"
className="diceInputField"
labelPlacement="stacked"
onIonInput={(e) => setInput(e.target.value)}
/>
</IonItem>
</div>
{fieldError && <p className="error">{fieldError}</p>}
<IonButton shape="round" className="submitButton" type="submit">
Roll
</IonButton>
</form>
);
}
I’m also going to create a function to validate the input. When the user clicks Roll, the function will check their input to make sure it’s good. If not, it will prompt them to fix it.
const validateInput = (input) => {
if (!input) {
setFieldError("Please enter a valid dice roll");
return false;
} else if (
!/^([+\-]?\d+d\d+|[+\-]?\d+)([+\-]\d+d\d+|[+\-]\d+)*$/.test(input)
) {
setFieldError(
`Please enter a valid dice roll. Example: 2d6+3, 1d20-1, 3d8-2+1d4`
);
setResults(null);
return false;
} else {
setFieldError(null);
return true;
}
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateInput(input)) {
return;
} else {
let response = await fetch(`/api/roll-dice`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ string: input }),
});
let data = await response.json();
if (response.ok) {
setResults(data);
setError(null);
} else {
setResults(null);
setError(data.userMessage);
}
}
};
Results
Displaying the results will be a little more complicated. We want it to look clean, but we also want to make sure the user can see exactly where their final result comes from when they do a big complicated roll.
To that end I’ve gone with a card view. It will display their final result in a large, bold font, and list out the details for where each of the parts of the result are coming from.
import {
IonGrid,
IonRow,
IonCol,
IonCard,
IonCardHeader,
IonCardTitle,
IonCardSubtitle,
IonCardContent,
} from "@ionic/react";
export default function Results({ results }) {
return (
<IonCard mode="ios" className="resultsCard">
<IonCardHeader>
<IonCardTitle mode="ios">Result: {results.finalResult}</IonCardTitle>
<IonCardSubtitle mode="ios">
{results.primaryRoll.dice}
{results.modifier.modifiers}
</IonCardSubtitle>
</IonCardHeader>
<IonCardContent>
<IonGrid>
<IonRow>
<IonCol>
<p>
{results.primaryRoll.dice} (
{results.primaryRoll.resultsArray.toString()})
</p>
</IonCol>
</IonRow>
{results.modifier.modifiers &&
results.modifier.parsedModifiersArray.map((modifier, index) => (
<IonRow key={index}>
<IonCol>
<p>
{modifier} (
{results.modifier.modifierResults[
index
].resultsArray.toString()}
)
</p>
</IonCol>
</IonRow>
))}
</IonGrid>
</IonCardContent>
</IonCard>
);
}
I’m using a combination of IonCard and the IonGrid to achieve this. You’ll also notice that I specify mode=”ios”
for the card. This was a matter of personal preference. Without specifying it would choose the Material UI style for for any non-iOS device. I didn’t think it looked good in Chrome, so I specified iOS. For the most part though the defaults all look great.
I’m reasonably happy with this, but I’m going to update our Figma board to add some additional things here. Specifically I want to show a history of results. When the user rolls again I want the current card to collapse down into a smaller version and be replaced by the newer one. The smaller cards will be toward the bottom in case the user needs to look back at their previous rolls for whatever reason.
Putting It Together
Finally we need to import these new components into App.jsx. Ion makes it easy to create responsive, mobile first designs that look great on every device.
import { useState } from "react";
import { setupIonicReact } from "@ionic/react";
import {
IonContent,
IonFooter,
IonHeader,
IonTitle,
IonToolbar,
} from "@ionic/react";
import "./App.css";
import "@ionic/react/css/core.css";
/* Basic CSS for apps built with Ionic */
import "@ionic/react/css/normalize.css";
import "@ionic/react/css/structure.css";
import "@ionic/react/css/typography.css";
/* Optional CSS utils that can be commented out */
import "@ionic/react/css/padding.css";
import "@ionic/react/css/float-elements.css";
import "@ionic/react/css/text-alignment.css";
import "@ionic/react/css/text-transformation.css";
import "@ionic/react/css/flex-utils.css";
import "@ionic/react/css/display.css";
import "@ionic/react/css/palettes/dark.system.css";
setupIonicReact();
import DiceRollForm from "./components/DiceRollForm";
import Results from "./components/Results";
function App() {
//text box to accept the string dice roll and a button to submit it, then display the results
const [results, setResults] = useState(null);
const [error, setError] = useState(null);
return (
<div className="mainContainer">
<IonHeader>
<IonToolbar>
<IonTitle>Dice Roller</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<DiceRollForm setResults={setResults} setError={setError} />
{results && <Results results={results} />}
</IonContent>
</div>
);
}
export default App;
And that friends, is a fully functional dice roller MVP. Tomorrow I want to start planning the next steps to really make it great. If you want to see the code you can check out the repo on my Github. Please leave a comment below if you have any feedback, tips, or tricks!