Project 2: llmPlay Compose
Cover Page
Expected behavior
DISCLAIMER: the video demoes show you one aspect of the app’s behavior. It is not a substitute for the spec. If there are any discrepancies between the demo and this spec, please follow the spec. The spec is the single source of truth. If the spec is ambiguous, please consult the teaching staff for clarification.
Preparing your GitHub repo
If you have not completed the llmChat tutorial, please do so first.
Then depending on whether you have completed the Maps tutorial, we have two
different preparatory paths. Please click on your option to proceed:
Maps tutorial completed
We’ll build on your Maps tutorial to take advantage of the dependencies
and permission request you’ve already done, but you’ll need to copy some
files over from your llmChat tutorial.
In the following, replace /YOUR:TUTORIALS/ with the name of your tutorials folder.
- On your laptop, navigate to
/YOUR:TUTORIALS/ - Create a zip of your
mapsfolder - Rename your
mapsfolderllmplay - Remove your
llmplay’s.gradledirectory by running in a shell window:laptop$ cd /YOUR:TUTORIALS/llmplay/composeChatter laptop$ rm -rf .gradle -
Open your
llmplayproject in Android Studio and other thanExtensions.ktandLocManager.kt, delete all your other.ktsource files, in order:MainActivity.kt,MapView.kt,ChattScrollView.kt,MainView.kt,ChattStore.kt,GeoData.kt, andChatt.kt.You can delete a file by selecting it and hitting the delete key on your keyboard.
- With your
llmplayproject open in Android Studio, open yourllmchatproject and copy over (alt-drag) the following files from yourllmchatproject:Chatt.kt,ChattStore.kt,MainView.kt, andMainActivity.kt. - Then migrate back the following lines from your
mapsproject:- In
MainActivity.kt, add toChattViewModel:val locManager = LocManager(app.applicationContext) - Replace your
MainActivityclass with:class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> if (!granted) { toast("Location access denied") finish() } LocManager.permission.value = true }.launch(Manifest.permission.ACCESS_FINE_LOCATION) setContent { if (LocManager.permission.value) { LocManager.init(applicationContext) LocManager.StartUpdatesWithLifecycle() MainView() } } } }
- In
Maps tutorial not completed
We’ll build on your llmChat tutorial but you need to add the dependencies
and permission request to access device location and maps.
In the following, replace /YOUR:TUTORIALS/ with the name of your tutorials folder.
- On your laptop, navigate to
/YOUR:TUTORIALS/ - Create a zip of your
llmchatfolder - Rename your
llmchatfolderllmplay - Remove your llmplay’s
.gradledirectory by running in a shell window:laptop$ cd /YOUR:TUTORIALS/llmplay/composeChatter laptop$ rm -rf .gradle - Click on these links to the
Mapstutorial and follow the instructions in each section:
- In your
/app/res/values/strings.xmlfile, set your"message"string to a single space, “”, instead ofhowdy? - Delete the file
ChattScrollView.ktfrom your project - Push your local
/YOUR:TUTORIALS/repo to GitHub and make sure there’re no git issues:git push
- Open GitHub Desktop and click on
Current Repositoryon the top left of the interface - Click on your
reactiveGitHub repo - Add Summary to your changes and click
Commit to main - If you have pushed other changes to your Git repo,
click
Pull Originto synch up the clone on your laptop - Finally click on
Push Originto push changes to GitHub
- Open GitHub Desktop and click on
Go to the GitHub website to confirm that your folders follow this structure outline:
reactive
|-- chatterd
|-- chatterd.crt
|-- llmchat
|-- llmplay
|-- composeChatter
|-- app
|-- gradle
# and other files or folders
If the folders in your GitHub repo does not have the above structure, we will not be able to grade your assignment and you will get a ZERO.
Interacting with Ollama
I think of the interaction with Ollama as separated into three phases:
- When the app launches, it starts a game by prompting the model, through
chatterd’s/llmprepAPI, for a set of hints about a city. In the same prompt, it also specifies what the model must do to announce that the user has guessed correctly. - As the game progress, the app interacts with the model through
chatterd’s modified/llmchatAPI: receiving hints, sending guesses. We’ll be using a modifiedllmChat(), which I callllmPlay(), to handle this phase. - Finally, the app must be able to receive, recognize, and handle a
LatLonSSE event thatchatterdsends when Ollama announces a winner.
When the app launches, in the onCreate() method of my MainActivity, after I’ve registered for
location access permission, and before calling setContent(), I send the system prompt as described
in the Front-end UX section using llmPrep() (see the llmPrep
TODO in the llmChat tutorial for an example usage). After sending
the system prompt, I follow with sending a single “Yes” string using llmPlay() to prime the model
and get it to return the first set of hints about the mystery city.
Be careful with the wording of your prompt. For example, opening your session with a “Test” or “This is a test” prompt could lead to confusing subsequent interaction with the model.
I collect the hints returned by Ollama in an observable property hints in my ChattViewModel,
also in MainActivity.kt. Since this initial prompt will never result in any winning notification,
I give null as the argument for the winner parameter.
Location class
To communicate lat/lon information between functions, I define the following
data class in my LocManager.kt file:
@Serializable
@JsonIgnoreUnknownKeys
data class Location(
var lat: Double = 0.0,
var lon: Double = 0.0
)
llmPlay()
Here’s the full signature I use for llmPlay() in ChattStore:
suspend fun llmPlay(appID: String?, chatt: Chatt,
hints: MutableState<String>,
winner: ((Location) -> Unit)?,
errMsg: MutableState<String>) { }
The first, second, and last parameters are the same as those of llmChat(). The hints parameter
is an observable string we use to hold hints Ollama returns about the city for the user to guess. We
update hints similar to how we update errMsg. Appending newly arriving chunks to hints
accumulates the hints. Compose automatically re-composes any composable observing a
MutableState<T> variable, such as hints and errMsg. The winner parameter is a lambda
expression that takes a Location, as argument and handles the winning notification. Location is
defined above in LocManager.kt.
I will build llmPlay() by modifying llmChat(), as follows. Unlike in the llmChat tutorial, we
do not need to show a timeline of user exchanges with Ollama. Thus, instead of creating a placeholder
chatt message to append to a chatts array, as we did in llmChat(), you can decode each
Message data line of the returning stream into an OllamaResponse class and append its content
to the hints parameter directly. Remember to clear the hints parameter before
you start processing Ollama’s reply and start appending to it.
Handling of SSE Error event and all other error handling code can be adopted from llmChat().
To recognize and handle a LatLon SSE event, you may want to read closely the llmChat() code for
handling SSE Error event, and the accompanying explanation in the llmChat
tutorial. To implement SSE LatLon event handling, first add a
LatLon enum constant to your SseEventType at the top of ChattStore file. Then for each line of
the incoming stream, when you detect an event tag, in addition transitioning to an Error event, you want
to recognize and transition to a LatLon event.
When parsing a data line, if it’s part of a LatLon event, decode the line into a Location data
class, not an OllamaResponse data class, and call the winner lambda expression with the
decoded Location as its argument. Since winner can be null, to invoke it, you must use the
invoke method, after checking that it is indeed not null:
winner?.invoke(location)
We will discuss what goes into the winner lambda expression at llmPlay() use site in
SubmitButton later.
Game UI
The Game UI is simply the MainView with a GoogleMap taking the place of ChattScrollView. We
want to show the GoogleMap only when the location read on our device has yielded some data. We
also want to move the map’s camera around when there’s a winner notification.
First add a cameraPositionState variable to your MainView() composable. This gives us a handle
on the camera that we will use to move it around the map.
val cameraPositionState = rememberCameraPositionState()
You can remove the listScroll variable as we will no longer be showing a
timeline of chatts.
Then, guard your call to GoogleMap() with a conditional check on the availability of location
data. If location data is not (yet) available, show a CircularProgressIndicator as we do with
SubmitButton:
if (LocManager.location.value.latitude != 0.0 || LocManager.location.value.longitude != 0.0) {
// show GoogleMap()
} else {
// show CircularProgressIndicator()
}
When we displayed GoogleMap() in the Maps tutorial, we initialized it with a camera position for
its cameraPositionState parameter. For this programming assignment, initialize GoogleMap() with
the “blank” cameraPositionState variable above. Then inside the content lambda, where we called
MarkerInfoWindow()s in the Maps tutorial, put the following LaunchedEffect() to position the
camera on launch instead:
LaunchedEffect(Unit) {
cameraPositionState.move(
CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder()
.target(LatLng(
LocManager.location.value.latitude,
LocManager.location.value.longitude
))
.zoom(14f).tilt(60f).build()
)
)
}
Below all that, we want to show the hints returned by Ollama in a Text() box above the
OutlinedTextField() where user enters their guesses. The content of the Text() box should be the
observable property hints in ChattViewModel where you’ve collected the hints from Ollama. Put
these two text boxes in a Column() with modifier .fillMaxWidth(.88f) and replace the
modifier parameter for OutlinedTextField() with:
modifier = Modifier
.fillMaxWidth(1f)
.padding(end = 18.dp),
Next to these two text boxes, we want to show the SubmitButton:
- You want to update the
SubmitButtonfrom thellmChattutorial to take an argument of typeCameraPositionState. - When calling
SubmitButton, since we don’t have a timeline ofchatts to show anymore, replace the parameterlistScrollwithcameraPositionStatewe defined above. Then inSubmitButtonremove the call toanimateScrolltToItem(), along with its surroundingwithContext(). - Also add the following local variable to
SubmitButton:val focus = LocalFocusManager.current - In
SubmitButton, callllmPlay()in lieu ofllmChat(), passing it thehintsproperty inChattViewModel. - As for the
winnerparameter ofllmPlay(), pass it the following lambda expression, which moves the camera on theGoogleMapto the city’s lat/lon:{ loc -> vm.viewModelScope.launch { withContext(AndroidUiDispatcher.Main) { cameraPositionState.move( CameraUpdateFactory.newLatLng( LatLng(loc.lat, loc.lon) ) ) focus.clearFocus() } } },
Finally, as usual, MainView must pop up an alert dialog box if there
were any error messages.
Additional UX (optional)
The following UX feature is intended to increase the perceived responsiveness and interactivity of the app. You can choose to implement it to match the demo video, but you won’t be deducted point if you don’t (nor will there be extra credit if you do!).
While waiting for the first batch of hints from Ollama, the message in the
text box holding the hints should show Waiting for hints… and the SubmitButton
is disabled and shows a CircularProgressIndicator() which changes back
to the “paper plane” icon when the hints finished arriving.
That’s all for Project 2!
Run and test to verify and debug
Be sure to run your front end against your back end. You will not get full credit if your front end is not set up to work with your back end!
Submission guidelines
Be sure you have submitted your modified back end in addition to submitting
your updated front end. As usual, we will only grade files committed to the
main branch. If you use multiple branches, please merge them all to the
main branch for submission.
Push your front-end code to the same GitHub repo you’ve submitted your back-end code:
- Open GitHub Desktop and click on
Current Repositoryon the top left of the interface - Click on the GitHub repo you created at the start of this tutorial
- Add Summary to your changes and click
Commit to mainat the bottom of the left pane - If you have pushed code to your repo, click
Pull Originto synch up the repo on your laptop - Finally click
Push Originto push all changes to GitHub
Go to the GitHub website to confirm that your front-end files have been uploaded to your GitHub
repo under the folder llmplay. Confirm that your repo has a folder structure outline similar to the following.
If your folder structure is not as outlined, our script will not pick up your submission and, further, you may
have problems getting started on latter tutorials. There could be other files or folders in your local folder
not listed below, don’t delete them. As long as you have installed the course .gitignore as per the instructions
in Preparing GitHub for Reactive Tutorials, only
files needed for grading will be pushed to GitHub.
reactive
|-- chatterd
|-- chatterd.crt
|-- llmchat
|-- llmplay
|-- composeChatter
|-- app
|-- gradle
# and other files or folders
Verify that your Git repo is set up correctly: on your laptop, grab a new clone of your repo and build and run your submission to make sure that it works. You will get ZERO point if your tutorial doesn’t build, run, or open.
IMPORTANT: If you work in a team, put your team mate’s name and uniqname in your repo’s README.md (click the pencil icon at the upper right corner of the README.md box on your git repo) so that we’d know. Otherwise, we could mistakenly think that you were cheating and accidentally report you to the Honor Council, which would be a hassle to undo. You don’t need a README.md if you work by yourself.
Review your information on the Tutorial and Project Links sheet. If you’ve changed your teaming arrangement from previous lab’s, please update your entry. If you’re using a different GitHub repo from previous lab’s, invite eecsreactive@umich.edu to your new GitHub repo and update your entry.
Appendix: imports
| Prepared by Chenglin Li, Xin Jie ‘Joyce’ Liu, Sugih Jamin | Last updated: February 6th, 2026 |